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
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* ChatChannel 단위 테스트(ADR-158/176) — 라이프사이클 훅이 **프레임워크 presence/broadcast API**
|
|
4
|
-
* (ctx.presence.join/list/broadcast)와 history redis(KV)를 올바르게 호출하는지 mock 으로 검증.
|
|
5
|
-
* 인프라 불필요(presence·redis native 는 spy). redis pub/sub·roster HASH 직접 호출은 더 이상 없다 —
|
|
6
|
-
* 클러스터 전파·접속자목록 동기화는 프레임워크(wsCluster, NATS)가 처리한다.
|
|
7
|
-
*/
|
|
8
|
-
import { describe, test, expect, vi, beforeEach } from 'vitest'
|
|
9
|
-
import { ChatChannel } from '../../../apps/main/channels/chat-channel.js'
|
|
10
|
-
|
|
11
|
-
/** native redis spy — history 리스트(전파/roster 아님, 단순 기록 KV). @param {object} [o] */
|
|
12
|
-
function fakeNative({ history = [] } = {}) {
|
|
13
|
-
return {
|
|
14
|
-
rpush: vi.fn(async () => 1),
|
|
15
|
-
ltrim: vi.fn(async () => 'OK'),
|
|
16
|
-
expire: vi.fn(async () => 1),
|
|
17
|
-
lrange: vi.fn(async () => history),
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 채널 ctx mock — auth/presence/cache/log. presence.list 가 클러스터 roster 를 흉내낸다.
|
|
23
|
-
* @param {object} [o]
|
|
24
|
-
*/
|
|
25
|
-
function makeCtx({ members = [{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } }], history = [], userName = 'kim' } = {}) {
|
|
26
|
-
const native = fakeNative({ history })
|
|
27
|
-
const presence = {
|
|
28
|
-
join: vi.fn(),
|
|
29
|
-
list: vi.fn(() => members),
|
|
30
|
-
broadcast: vi.fn(),
|
|
31
|
-
directToUser: vi.fn(),
|
|
32
|
-
}
|
|
33
|
-
return {
|
|
34
|
-
ctx: {
|
|
35
|
-
auth: { userId: 'u1', sessionId: 's1', userName },
|
|
36
|
-
presence,
|
|
37
|
-
cache: vi.fn(() => ({ native })),
|
|
38
|
-
log: { warn: vi.fn(), debug: vi.fn(), error: vi.fn() },
|
|
39
|
-
},
|
|
40
|
-
native,
|
|
41
|
-
presence,
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** sock mock — send spy + id + isOpen. */
|
|
46
|
-
function makeSock() {
|
|
47
|
-
return { id: 'conn-1', isOpen: true, send: vi.fn(), close: vi.fn() }
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
beforeEach(() => vi.clearAllMocks())
|
|
51
|
-
|
|
52
|
-
describe('ChatChannel.onConnect', () => {
|
|
53
|
-
test('presence.join(자동 roster) + 기록재생 + 입장 broadcast(본인 제외) + chat.history(워커PID)', async () => {
|
|
54
|
-
const { ctx, presence } = makeCtx({
|
|
55
|
-
members: [
|
|
56
|
-
{ sessionId: 's0', userId: 'u0', metadata: { userName: 'old' } },
|
|
57
|
-
{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } },
|
|
58
|
-
],
|
|
59
|
-
history: [JSON.stringify({ userId: 'u0', userName: 'old', text: 'hi', ts: 1 })],
|
|
60
|
-
})
|
|
61
|
-
const sock = makeSock()
|
|
62
|
-
await new ChatChannel().onConnect(sock, ctx)
|
|
63
|
-
|
|
64
|
-
// 신원 매핑 + 자동 roster 등록(프레임워크에 위임).
|
|
65
|
-
expect(presence.join).toHaveBeenCalledWith({ userId: 'u1', sessionId: 's1', channels: ['chat'], metadata: { userName: 'kim' } })
|
|
66
|
-
|
|
67
|
-
// 본인에게: chat.history(me + items + 클러스터 명단 + 워커PID).
|
|
68
|
-
const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
|
|
69
|
-
expect(hist.payload.me).toEqual({ userId: 'u1', userName: 'kim' })
|
|
70
|
-
expect(hist.payload.items).toHaveLength(1)
|
|
71
|
-
expect(hist.payload.online).toBe(2)
|
|
72
|
-
expect(hist.payload.members).toEqual(['old', 'kim'])
|
|
73
|
-
expect(hist.payload.workerPid).toBe(process.pid)
|
|
74
|
-
|
|
75
|
-
// 전 클러스터에: 입장 presence broadcast(본인 sessionId 제외). NATS fan-out 은 프레임워크 처리.
|
|
76
|
-
const env = presence.broadcast.mock.calls.at(-1)[0]
|
|
77
|
-
expect(env).toMatchObject({
|
|
78
|
-
channel: 'chat',
|
|
79
|
-
message: { type: 'chat.presence', payload: { event: 'join', userName: 'kim', online: 2 } },
|
|
80
|
-
exceptSessionIds: ['s1'],
|
|
81
|
-
})
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
test('redis(history) 없으면 빈 기록으로 연결 유지(close 안 함)', async () => {
|
|
85
|
-
const presence = { join: vi.fn(), list: vi.fn(() => [{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } }]), broadcast: vi.fn() }
|
|
86
|
-
const ctx = { auth: { userId: 'u1', sessionId: 's1', userName: 'kim' }, presence, cache: vi.fn(() => ({ native: null })), log: { warn: vi.fn(), debug: vi.fn() } }
|
|
87
|
-
const sock = makeSock()
|
|
88
|
-
await new ChatChannel().onConnect(sock, ctx)
|
|
89
|
-
expect(sock.close).not.toHaveBeenCalled()
|
|
90
|
-
expect(presence.join).toHaveBeenCalled()
|
|
91
|
-
const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
|
|
92
|
-
expect(hist.payload.items).toEqual([])
|
|
93
|
-
})
|
|
94
|
-
|
|
95
|
-
test('손상된 기록 1건은 건너뛰고 나머지는 재생(debug 로그)', async () => {
|
|
96
|
-
const { ctx } = makeCtx({ history: ['{bad', JSON.stringify({ userName: 'a', text: 'ok', ts: 2 })] })
|
|
97
|
-
const sock = makeSock()
|
|
98
|
-
await new ChatChannel().onConnect(sock, ctx)
|
|
99
|
-
const hist = sock.send.mock.calls.find(([m]) => m.type === 'chat.history')[0]
|
|
100
|
-
expect(hist.payload.items).toHaveLength(1)
|
|
101
|
-
expect(ctx.log.debug).toHaveBeenCalled()
|
|
102
|
-
})
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
describe('ChatChannel.chat.send', () => {
|
|
106
|
-
test('검증된 text 를 기록 적재 + 전 클러스터 broadcast(본인 포함)', async () => {
|
|
107
|
-
const { ctx, native, presence } = makeCtx()
|
|
108
|
-
await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' hello ' } }, ctx)
|
|
109
|
-
expect(native.rpush).toHaveBeenCalled()
|
|
110
|
-
expect(native.ltrim).toHaveBeenCalledWith('ws:chat:history', -30, -1)
|
|
111
|
-
const env = presence.broadcast.mock.calls.at(-1)[0]
|
|
112
|
-
expect(env.message.type).toBe('chat.msg')
|
|
113
|
-
expect(env.message.payload).toMatchObject({ userId: 'u1', userName: 'kim', text: 'hello' })
|
|
114
|
-
expect(env.exceptSessionIds).toBeUndefined() // 본인도 echo.
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
test('공백뿐인 메시지는 무시(전파·적재 없음)', async () => {
|
|
118
|
-
const { ctx, native, presence } = makeCtx()
|
|
119
|
-
await new ChatChannel()['chat.send'](makeSock(), { type: 'chat.send', payload: { text: ' ' } }, ctx)
|
|
120
|
-
expect(presence.broadcast).not.toHaveBeenCalled()
|
|
121
|
-
expect(native.rpush).not.toHaveBeenCalled()
|
|
122
|
-
})
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
describe('ChatChannel.onDisconnect', () => {
|
|
126
|
-
test('퇴장 broadcast(본인 명단 제외) — roster 제거는 프레임워크 자동', async () => {
|
|
127
|
-
// disconnect 시점에 roster 가 아직 본인을 포함할 수도 있으므로, 채널은 sessionId 로 명시 제외해야 한다.
|
|
128
|
-
const { ctx, presence } = makeCtx({
|
|
129
|
-
members: [
|
|
130
|
-
{ sessionId: 's1', userId: 'u1', metadata: { userName: 'kim' } },
|
|
131
|
-
{ sessionId: 's2', userId: 'u2', metadata: { userName: 'lee' } },
|
|
132
|
-
],
|
|
133
|
-
})
|
|
134
|
-
const sock = makeSock()
|
|
135
|
-
await new ChatChannel().onDisconnect(sock, ctx)
|
|
136
|
-
const env = presence.broadcast.mock.calls.at(-1)[0]
|
|
137
|
-
expect(env.message).toMatchObject({ type: 'chat.presence', payload: { event: 'leave', userName: 'kim' } })
|
|
138
|
-
expect(env.message.payload.members).toEqual(['lee']) // 본인(s1) 제외.
|
|
139
|
-
expect(env.message.payload.online).toBe(1)
|
|
140
|
-
expect(env.exceptSessionIds).toEqual(['s1'])
|
|
141
|
-
})
|
|
142
|
-
|
|
143
|
-
test('auth 없으면 전파 없음', async () => {
|
|
144
|
-
const { ctx, presence } = makeCtx()
|
|
145
|
-
ctx.auth = null
|
|
146
|
-
await new ChatChannel().onDisconnect(makeSock(), ctx)
|
|
147
|
-
expect(presence.broadcast).not.toHaveBeenCalled()
|
|
148
|
-
})
|
|
149
|
-
})
|
|
@@ -1,93 +0,0 @@
|
|
|
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
|
-
})
|
|
@@ -1,386 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* 데모 흐름 통합 테스트(ADR-157) — 실 mongo(notes) + redis(카운터·캐시) + postgres(세션 사용자)로 sample/crud 를
|
|
4
|
-
* 부팅하고 HTTP 전 경로를 검증한다: 비로그인 가드 → 로그인 → notes CRUD(mongo) → redis 방문 카운터·캐시 hit/miss.
|
|
5
|
-
*
|
|
6
|
-
* 인프라(pg·redis·mongo) env 가 없으면 통째로 skip 한다(단위 테스트가 인프라 없이 로직을 커버).
|
|
7
|
-
* 실행에 필요한 env(.env): DATABASE_URL·REDIS_SESSION_URL·REDIS_RATE_URL·REDIS_DEMO_URL·MONGO_URL·SESSION_SECRET.
|
|
8
|
-
*/
|
|
9
|
-
import { describe, test, expect, beforeAll, afterAll } from 'vitest'
|
|
10
|
-
import { bootApp, MegaShutdown } from 'mega-framework'
|
|
11
|
-
import { fileURLToPath } from 'node:url'
|
|
12
|
-
import { dirname, resolve } from 'node:path'
|
|
13
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs'
|
|
14
|
-
import { User } from '../../../apps/main/models/user.js'
|
|
15
|
-
import { Note } from '../../../apps/main/models/note.js'
|
|
16
|
-
import { RedisDemoService } from '../../../apps/main/services/redis-demo-service.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(
|
|
25
|
-
process.env.DATABASE_URL &&
|
|
26
|
-
process.env.REDIS_SESSION_URL &&
|
|
27
|
-
process.env.REDIS_RATE_URL &&
|
|
28
|
-
process.env.REDIS_DEMO_URL &&
|
|
29
|
-
process.env.MONGO_URL &&
|
|
30
|
-
process.env.SESSION_SECRET,
|
|
31
|
-
)
|
|
32
|
-
const d = hasInfra ? describe : describe.skip
|
|
33
|
-
|
|
34
|
-
/** set-cookie 를 누적 쿠키 jar 에 반영(maxAge=0 → 삭제). @param {any} res @param {Record<string,string>} jar */
|
|
35
|
-
function applyCookies(res, jar) {
|
|
36
|
-
const raw = res.headers['set-cookie']
|
|
37
|
-
const arr = Array.isArray(raw) ? raw : raw ? [raw] : []
|
|
38
|
-
for (const c of arr) {
|
|
39
|
-
const pair = String(c).split(';')[0]
|
|
40
|
-
const eq = pair.indexOf('=')
|
|
41
|
-
if (eq === -1) continue
|
|
42
|
-
const name = pair.slice(0, eq).trim()
|
|
43
|
-
const val = pair.slice(eq + 1).trim()
|
|
44
|
-
if (val === '') delete jar[name]
|
|
45
|
-
else jar[name] = val
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** @param {Record<string,string>} jar @returns {string} Cookie 헤더. */
|
|
50
|
-
function cookieHeader(jar) {
|
|
51
|
-
return Object.entries(jar).map(([k, v]) => `${k}=${v}`).join('; ')
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** 렌더된 폼 HTML 에서 hidden `_csrf` 토큰을 뽑는다. @param {string} html @returns {string} */
|
|
55
|
-
function csrfFrom(html) {
|
|
56
|
-
const m = /name="_csrf" value="([^"]+)"/.exec(html)
|
|
57
|
-
if (!m) throw new Error('csrf token not found in form HTML')
|
|
58
|
-
return m[1]
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** urlencoded 폼 바디 직렬화. @param {Record<string,string>} fields @returns {string} */
|
|
62
|
-
function form(fields) {
|
|
63
|
-
return Object.entries(fields).map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&')
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** 업로드 폼의 data-csrf 속성에서 토큰을 뽑는다(multipart 는 헤더로 보냄). @param {string} html @returns {string} */
|
|
67
|
-
function csrfDataFrom(html) {
|
|
68
|
-
const m = /data-csrf="([^"]+)"/.exec(html)
|
|
69
|
-
if (!m) throw new Error('data-csrf token not found in upload form HTML')
|
|
70
|
-
return m[1]
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** N개 파일 multipart/form-data 본문 조립(추가 dep 없이). @param {Array<{ name?: string, filename?: string, contentType?: string, value?: string }>} files @param {string} [boundary] */
|
|
74
|
-
function multipartBody(files, boundary = '----megademo') {
|
|
75
|
-
const chunks = []
|
|
76
|
-
for (const f of files) {
|
|
77
|
-
chunks.push(
|
|
78
|
-
Buffer.from(
|
|
79
|
-
`--${boundary}\r\nContent-Disposition: form-data; name="${f.name ?? 'file'}"; filename="${f.filename ?? 'a.bin'}"\r\n` +
|
|
80
|
-
`Content-Type: ${f.contentType ?? 'application/octet-stream'}\r\n\r\n`,
|
|
81
|
-
'utf8',
|
|
82
|
-
),
|
|
83
|
-
)
|
|
84
|
-
chunks.push(Buffer.from(String(f.value ?? ''), 'utf8'))
|
|
85
|
-
chunks.push(Buffer.from('\r\n', 'utf8'))
|
|
86
|
-
}
|
|
87
|
-
chunks.push(Buffer.from(`--${boundary}--\r\n`, 'utf8'))
|
|
88
|
-
return { body: Buffer.concat(chunks), contentType: `multipart/form-data; boundary=${boundary}` }
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
d('데모 흐름 E2E — sample/crud 실 mongo+redis+pg (ADR-157)', () => {
|
|
92
|
-
/** @type {Awaited<ReturnType<typeof bootApp>>} */
|
|
93
|
-
let boot
|
|
94
|
-
/** @type {any} */
|
|
95
|
-
let fastify
|
|
96
|
-
/** @type {Record<string,string>} 로그인 세션 쿠키 jar. */
|
|
97
|
-
const jar = {}
|
|
98
|
-
const EMAIL = `itest-demo-${Date.now()}@example.com`
|
|
99
|
-
const PASSWORD = 'secret-pass-123'
|
|
100
|
-
const NAME = 'Demo Tester'
|
|
101
|
-
|
|
102
|
-
beforeAll(async () => {
|
|
103
|
-
MegaShutdown._reset()
|
|
104
|
-
boot = await bootApp(PROJECT, { listen: false })
|
|
105
|
-
const app = boot.megaApps.find((a) => a.name === 'main')
|
|
106
|
-
fastify = app?.fastify
|
|
107
|
-
await fastify.ready()
|
|
108
|
-
// 스키마 보장(멱등) + 테스트 계정 정리.
|
|
109
|
-
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())')
|
|
110
|
-
await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash TEXT')
|
|
111
|
-
await User.query('ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ')
|
|
112
|
-
await User.query('DELETE FROM users WHERE email = $1', [EMAIL])
|
|
113
|
-
|
|
114
|
-
// 회원가입 → 자동 로그인 → 세션 쿠키 확보(가드된 /demo/** 접근에 필요).
|
|
115
|
-
const regForm = await fastify.inject({ method: 'GET', url: '/register' })
|
|
116
|
-
applyCookies(regForm, jar)
|
|
117
|
-
const reg = await fastify.inject({
|
|
118
|
-
method: 'POST',
|
|
119
|
-
url: '/register',
|
|
120
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
121
|
-
payload: form({ _csrf: csrfFrom(regForm.body), name: NAME, email: EMAIL, password: PASSWORD }),
|
|
122
|
-
})
|
|
123
|
-
applyCookies(reg, jar)
|
|
124
|
-
})
|
|
125
|
-
|
|
126
|
-
afterAll(async () => {
|
|
127
|
-
if (!boot) return
|
|
128
|
-
await User.query('DELETE FROM users WHERE email = $1', [EMAIL]).catch(() => {})
|
|
129
|
-
await Note.collection.deleteMany({ title: { $regex: /^itest / } }).catch(() => {})
|
|
130
|
-
await fastify?.close().catch(() => {})
|
|
131
|
-
const app = boot.megaApps.find((a) => a.name === 'main')
|
|
132
|
-
await app?.sessionStore?.disconnect().catch(() => {})
|
|
133
|
-
await boot.ctx.cache('rate').disconnect().catch(() => {})
|
|
134
|
-
await boot.ctx.cache('demo').disconnect().catch(() => {})
|
|
135
|
-
await boot.ctx.db('mongo').disconnect().catch(() => {})
|
|
136
|
-
await boot.ctx.db('primary').disconnect().catch(() => {})
|
|
137
|
-
MegaShutdown._reset()
|
|
138
|
-
})
|
|
139
|
-
|
|
140
|
-
test('비로그인: /demo/notes 와 /demo/redis 는 로그인으로 302', async () => {
|
|
141
|
-
const notes = await fastify.inject({ method: 'GET', url: '/demo/notes' })
|
|
142
|
-
expect(notes.statusCode).toBe(302)
|
|
143
|
-
expect(notes.headers.location).toBe('/auth/login')
|
|
144
|
-
const redis = await fastify.inject({ method: 'GET', url: '/demo/redis' })
|
|
145
|
-
expect(redis.statusCode).toBe(302)
|
|
146
|
-
expect(redis.headers.location).toBe('/auth/login')
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
test('notes CRUD (mongo): 생성 → 목록 표시 → 수정 → 삭제', async () => {
|
|
150
|
-
const TITLE = `itest ${Date.now()}`
|
|
151
|
-
const EDITED = `${TITLE} (edited)`
|
|
152
|
-
|
|
153
|
-
// 신규 폼 GET — CSRF 토큰.
|
|
154
|
-
const newForm = await fastify.inject({ method: 'GET', url: '/demo/notes/new', headers: { cookie: cookieHeader(jar) } })
|
|
155
|
-
expect(newForm.statusCode).toBe(200)
|
|
156
|
-
|
|
157
|
-
// 생성 POST → 목록으로 302.
|
|
158
|
-
const created = await fastify.inject({
|
|
159
|
-
method: 'POST',
|
|
160
|
-
url: '/demo/notes',
|
|
161
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
162
|
-
payload: form({ _csrf: csrfFrom(newForm.body), title: TITLE, body: 'hello mongo' }),
|
|
163
|
-
})
|
|
164
|
-
expect(created.statusCode).toBe(302)
|
|
165
|
-
expect(created.headers.location).toBe('/demo/notes?notice=created')
|
|
166
|
-
|
|
167
|
-
// 목록에 방금 만든 제목이 보인다.
|
|
168
|
-
const list = await fastify.inject({ method: 'GET', url: '/demo/notes', headers: { cookie: cookieHeader(jar) } })
|
|
169
|
-
expect(list.statusCode).toBe(200)
|
|
170
|
-
expect(list.body).toContain(TITLE)
|
|
171
|
-
|
|
172
|
-
// 방금 만든 노트의 id 를 모델로 직접 찾는다(목록 HTML 파싱 대신 — edit URL 구성용).
|
|
173
|
-
const all = await Note.list()
|
|
174
|
-
const mine = /** @type {any} */ (all).find((n) => n.title === TITLE)
|
|
175
|
-
expect(mine).toBeTruthy()
|
|
176
|
-
|
|
177
|
-
// 수정 폼 GET → 수정 POST → 목록으로 302.
|
|
178
|
-
const editForm = await fastify.inject({ method: 'GET', url: `/demo/notes/${mine.id}/edit`, headers: { cookie: cookieHeader(jar) } })
|
|
179
|
-
expect(editForm.statusCode).toBe(200)
|
|
180
|
-
expect(editForm.body).toContain(TITLE)
|
|
181
|
-
const updated = await fastify.inject({
|
|
182
|
-
method: 'POST',
|
|
183
|
-
url: `/demo/notes/${mine.id}`,
|
|
184
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
185
|
-
payload: form({ _csrf: csrfFrom(editForm.body), title: EDITED, body: 'edited body' }),
|
|
186
|
-
})
|
|
187
|
-
expect(updated.statusCode).toBe(302)
|
|
188
|
-
expect(await Note.findById(mine.id)).toMatchObject({ title: EDITED })
|
|
189
|
-
|
|
190
|
-
// 삭제 POST → 목록으로 302, 모델에서도 사라진다.
|
|
191
|
-
const delList = await fastify.inject({ method: 'GET', url: '/demo/notes', headers: { cookie: cookieHeader(jar) } })
|
|
192
|
-
const del = await fastify.inject({
|
|
193
|
-
method: 'POST',
|
|
194
|
-
url: `/demo/notes/${mine.id}/delete`,
|
|
195
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
196
|
-
payload: form({ _csrf: csrfFrom(delList.body) }),
|
|
197
|
-
})
|
|
198
|
-
expect(del.statusCode).toBe(302)
|
|
199
|
-
expect(del.headers.location).toBe('/demo/notes?notice=deleted')
|
|
200
|
-
expect(await Note.findById(mine.id)).toBeNull()
|
|
201
|
-
})
|
|
202
|
-
|
|
203
|
-
test('redis 방문 카운터: 페이지 로드마다 누적 카운터가 1씩 증가한다', async () => {
|
|
204
|
-
const redis = boot.ctx.cache('demo').native
|
|
205
|
-
const before = Number((await redis.get(RedisDemoService.VISITS_TOTAL_KEY)) ?? 0)
|
|
206
|
-
await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
|
|
207
|
-
await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
|
|
208
|
-
const after = Number(await redis.get(RedisDemoService.VISITS_TOTAL_KEY))
|
|
209
|
-
expect(after).toBe(before + 2)
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
test('redis 캐시: 비운 직후 첫 로드는 miss, 다음 로드는 hit', async () => {
|
|
213
|
-
// 캐시 키를 직접 비워 결정적 상태로 만든다.
|
|
214
|
-
await boot.ctx.cache('demo').del(RedisDemoService.USER_COUNT_KEY)
|
|
215
|
-
|
|
216
|
-
const miss = await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
|
|
217
|
-
expect(miss.statusCode).toBe(200)
|
|
218
|
-
expect(miss.body).toContain('캐시 미스') // 기본 로케일 ko.
|
|
219
|
-
|
|
220
|
-
const hit = await fastify.inject({ method: 'GET', url: '/demo/redis', headers: { cookie: cookieHeader(jar) } })
|
|
221
|
-
expect(hit.statusCode).toBe(200)
|
|
222
|
-
expect(hit.body).toContain('캐시 적중')
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
// ── 관측 데모(ADR-163) — 메트릭/Swagger/트레이싱/로그/업로드 ──────────────────────────
|
|
226
|
-
|
|
227
|
-
test('관측 데모 비로그인 가드: 메트릭/트레이싱/로그/업로드/docs 는 로그인으로 302', async () => {
|
|
228
|
-
for (const url of ['/demo/metrics', '/demo/tracing', '/demo/logs', '/demo/upload', '/docs']) {
|
|
229
|
-
const res = await fastify.inject({ method: 'GET', url })
|
|
230
|
-
expect(res.statusCode, url).toBe(302)
|
|
231
|
-
expect(res.headers.location, url).toBe('/auth/login')
|
|
232
|
-
}
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
test('메트릭 데모: 로그인 시 200 + 카드 렌더', async () => {
|
|
236
|
-
const res = await fastify.inject({ method: 'GET', url: '/demo/metrics', headers: { cookie: cookieHeader(jar) } })
|
|
237
|
-
expect(res.statusCode).toBe(200)
|
|
238
|
-
expect(res.body).toContain('메트릭') // h1 title(ko).
|
|
239
|
-
})
|
|
240
|
-
|
|
241
|
-
test('Swagger: /docs/json 이 라우트 schema 를 수집한 OpenAPI 명세를 준다', async () => {
|
|
242
|
-
const res = await fastify.inject({ method: 'GET', url: '/docs/json', headers: { cookie: cookieHeader(jar) } })
|
|
243
|
-
expect(res.statusCode).toBe(200)
|
|
244
|
-
const spec = res.json()
|
|
245
|
-
expect(spec.openapi).toMatch(/^3\./)
|
|
246
|
-
// users 라우트가 자동 수집됐는지(경로 + 태그).
|
|
247
|
-
expect(spec.paths['/users']).toBeTruthy()
|
|
248
|
-
expect(JSON.stringify(spec)).toContain('users')
|
|
249
|
-
})
|
|
250
|
-
|
|
251
|
-
test('트레이싱 데모: 200 + generate POST(사용자 span + DB 핑) 302', async () => {
|
|
252
|
-
const page = await fastify.inject({ method: 'GET', url: '/demo/tracing', headers: { cookie: cookieHeader(jar) } })
|
|
253
|
-
expect(page.statusCode).toBe(200)
|
|
254
|
-
const gen = await fastify.inject({
|
|
255
|
-
method: 'POST',
|
|
256
|
-
url: '/demo/tracing/generate',
|
|
257
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
258
|
-
payload: form({ _csrf: csrfFrom(page.body) }),
|
|
259
|
-
})
|
|
260
|
-
expect(gen.statusCode).toBe(302)
|
|
261
|
-
expect(gen.headers.location).toBe('/demo/tracing?notice=generated')
|
|
262
|
-
})
|
|
263
|
-
|
|
264
|
-
test('로그 데모: emit POST 302 → 최근 목록에 메시지 표시', async () => {
|
|
265
|
-
const page = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
|
|
266
|
-
expect(page.statusCode).toBe(200)
|
|
267
|
-
const MSG = `itest log ${Date.now()}`
|
|
268
|
-
const emit = await fastify.inject({
|
|
269
|
-
method: 'POST',
|
|
270
|
-
url: '/demo/logs/emit',
|
|
271
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
272
|
-
payload: form({ _csrf: csrfFrom(page.body), level: 'warn', message: MSG }),
|
|
273
|
-
})
|
|
274
|
-
expect(emit.statusCode).toBe(302)
|
|
275
|
-
expect(emit.headers.location).toBe('/demo/logs?notice=emitted')
|
|
276
|
-
const after = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
|
|
277
|
-
expect(after.body).toContain(MSG)
|
|
278
|
-
})
|
|
279
|
-
|
|
280
|
-
// ── 가이드 뷰어(/guide) — 라우터 params 스키마 검증(ADR-019) + i18n ──────────────────────
|
|
281
|
-
|
|
282
|
-
test('가이드 목록: 로그인 시 200 + 카드 그리드', async () => {
|
|
283
|
-
const res = await fastify.inject({ method: 'GET', url: '/guide', headers: { cookie: cookieHeader(jar) } })
|
|
284
|
-
expect(res.statusCode).toBe(200)
|
|
285
|
-
expect(res.body).toContain('가이드') // guide_index_title(ko).
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
test('가이드 단일: 유효 slug 는 200 + 목차 렌더', async () => {
|
|
289
|
-
const res = await fastify.inject({ method: 'GET', url: '/guide/01-cli', headers: { cookie: cookieHeader(jar) } })
|
|
290
|
-
expect(res.statusCode).toBe(200)
|
|
291
|
-
expect(res.body).toContain('목차') // guide_toc(ko) — 좌측 목차.
|
|
292
|
-
})
|
|
293
|
-
|
|
294
|
-
test('가이드 단일: 형식 위반 slug 는 라우터 params 스키마가 400(validation.failed)으로 막는다', async () => {
|
|
295
|
-
for (const bad of ['INVALID', 'foo_bar', 'a.b']) {
|
|
296
|
-
const res = await fastify.inject({ method: 'GET', url: `/guide/${encodeURIComponent(bad)}`, headers: { cookie: cookieHeader(jar) } })
|
|
297
|
-
expect(res.statusCode, bad).toBe(400)
|
|
298
|
-
expect(res.json().error.code, bad).toBe('validation.failed')
|
|
299
|
-
}
|
|
300
|
-
})
|
|
301
|
-
|
|
302
|
-
test('가이드 단일: 형식은 맞지만 없는 slug 는 서비스가 404(guide.not_found)', async () => {
|
|
303
|
-
const res = await fastify.inject({ method: 'GET', url: '/guide/99-does-not-exist', headers: { cookie: cookieHeader(jar) } })
|
|
304
|
-
expect(res.statusCode).toBe(404)
|
|
305
|
-
expect(res.json().error.code).toBe('guide.not_found')
|
|
306
|
-
})
|
|
307
|
-
|
|
308
|
-
test('i18n: 검증 에러 메시지가 locale(ko 기본 / en 쿠키)로 번역된다', async () => {
|
|
309
|
-
const ko = await fastify.inject({ method: 'GET', url: '/guide/BAD', headers: { cookie: cookieHeader(jar) } })
|
|
310
|
-
expect(ko.json().error.message).toBe('입력값이 올바르지 않습니다.')
|
|
311
|
-
const en = await fastify.inject({ method: 'GET', url: '/guide/BAD', headers: { cookie: `${cookieHeader(jar)}; mega.lang=en` } })
|
|
312
|
-
expect(en.json().error.message).toBe('Validation failed.')
|
|
313
|
-
})
|
|
314
|
-
|
|
315
|
-
test('로그 데모: message 가 상한(500자)을 넘으면 body 스키마가 400 으로 막는다', async () => {
|
|
316
|
-
const page = await fastify.inject({ method: 'GET', url: '/demo/logs', headers: { cookie: cookieHeader(jar) } })
|
|
317
|
-
const res = await fastify.inject({
|
|
318
|
-
method: 'POST',
|
|
319
|
-
url: '/demo/logs/emit',
|
|
320
|
-
headers: { cookie: cookieHeader(jar), 'content-type': 'application/x-www-form-urlencoded' },
|
|
321
|
-
payload: form({ _csrf: csrfFrom(page.body), level: 'info', message: 'x'.repeat(501) }),
|
|
322
|
-
})
|
|
323
|
-
expect(res.statusCode).toBe(400)
|
|
324
|
-
expect(res.json().error.code).toBe('validation.failed')
|
|
325
|
-
})
|
|
326
|
-
|
|
327
|
-
test('업로드 데모: multipart + csrf-token 헤더로 저장 → JSON envelope + 최근 목록', async () => {
|
|
328
|
-
// 폼 GET — _csrf 쿠키 확보 + data-csrf 토큰 추출(multipart 는 헤더로 전송).
|
|
329
|
-
const page = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
|
|
330
|
-
expect(page.statusCode).toBe(200)
|
|
331
|
-
applyCookies(page, jar)
|
|
332
|
-
const token = csrfDataFrom(page.body)
|
|
333
|
-
|
|
334
|
-
const FNAME = `itest-${Date.now()}.txt`
|
|
335
|
-
const { body, contentType } = multipartBody([{ filename: FNAME, contentType: 'text/plain', value: 'hello upload' }])
|
|
336
|
-
const up = await fastify.inject({
|
|
337
|
-
method: 'POST',
|
|
338
|
-
url: '/demo/upload',
|
|
339
|
-
headers: { cookie: cookieHeader(jar), 'content-type': contentType, 'csrf-token': token },
|
|
340
|
-
payload: body,
|
|
341
|
-
})
|
|
342
|
-
expect(up.statusCode).toBe(200)
|
|
343
|
-
const env = up.json()
|
|
344
|
-
expect(env.ok).toBe(true)
|
|
345
|
-
expect(env.data.files[0]).toMatchObject({ filename: FNAME, mimetype: 'text/plain' })
|
|
346
|
-
// 저장 경로는 프로젝트 루트 기준 상대(var/uploads/...) — 절대경로 비노출.
|
|
347
|
-
expect(env.data.files[0].path).toMatch(/^var[/\\]uploads[/\\]/)
|
|
348
|
-
|
|
349
|
-
// 실제 디스크에 저장됐는지 확인(프로젝트 루트 기준).
|
|
350
|
-
const savedAbs = resolve(PROJECT, env.data.files[0].path)
|
|
351
|
-
expect(existsSync(savedAbs)).toBe(true)
|
|
352
|
-
expect(readFileSync(savedAbs, 'utf8')).toBe('hello upload')
|
|
353
|
-
|
|
354
|
-
// 최근 업로드 목록에 파일명 + 다운로드 링크가 보인다.
|
|
355
|
-
const after = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
|
|
356
|
-
expect(after.body).toContain(FNAME)
|
|
357
|
-
expect(after.body).toContain(`/demo/upload/file/${encodeURIComponent(FNAME)}`)
|
|
358
|
-
|
|
359
|
-
// 다운로드 — 인증 사용자는 소스에서 읽은 파일 내용을 받는다(정적 서빙 아님).
|
|
360
|
-
const dl = await fastify.inject({ method: 'GET', url: `/demo/upload/file/${encodeURIComponent(FNAME)}`, headers: { cookie: cookieHeader(jar) } })
|
|
361
|
-
expect(dl.statusCode).toBe(200)
|
|
362
|
-
expect(dl.body).toBe('hello upload')
|
|
363
|
-
expect(dl.headers['content-disposition']).toContain('attachment')
|
|
364
|
-
|
|
365
|
-
// 비로그인 다운로드는 로그인으로 리다이렉트(가드).
|
|
366
|
-
const dlGuard = await fastify.inject({ method: 'GET', url: `/demo/upload/file/${encodeURIComponent(FNAME)}` })
|
|
367
|
-
expect(dlGuard.statusCode).toBe(302)
|
|
368
|
-
expect(dlGuard.headers.location).toBe('/auth/login')
|
|
369
|
-
|
|
370
|
-
rmSync(savedAbs, { force: true }) // 테스트 산출물 정리.
|
|
371
|
-
})
|
|
372
|
-
|
|
373
|
-
test('업로드 데모: 비허용 MIME 은 415 로 거부', async () => {
|
|
374
|
-
const page = await fastify.inject({ method: 'GET', url: '/demo/upload', headers: { cookie: cookieHeader(jar) } })
|
|
375
|
-
applyCookies(page, jar)
|
|
376
|
-
const token = csrfDataFrom(page.body)
|
|
377
|
-
const { body, contentType } = multipartBody([{ filename: 'evil.bin', contentType: 'application/octet-stream', value: 'x' }])
|
|
378
|
-
const up = await fastify.inject({
|
|
379
|
-
method: 'POST',
|
|
380
|
-
url: '/demo/upload',
|
|
381
|
-
headers: { cookie: cookieHeader(jar), 'content-type': contentType, 'csrf-token': token },
|
|
382
|
-
payload: body,
|
|
383
|
-
})
|
|
384
|
-
expect(up.statusCode).toBe(415)
|
|
385
|
-
})
|
|
386
|
-
})
|