import { BadRequestException, Controller, Get, HttpStatus, } from '@nestjs/common'; import type { ExpressAdapter, NestExpressApplication, } from '@nestjs/platform-express'; import { Test } from '@nestjs/testing'; import type { Request, Response } from 'express'; import type { IncomingMessage, ServerResponse } from 'http'; import request from 'supertest'; import { startCpuTimer } from '@/../test/cpu-time'; import { BusinessException } from '@/common/errors/business.exception'; import { MessageDeletionDto } from '@/modules/mail/dto/bulk-messages.dto'; import { MailFilterUpdateDto } from '@/modules/mail/dto/mail-filter.dto'; import { SendMessageDto } from '@/modules/mail/dto/send-message.dto'; import { JSON_SHAPE_LIMITS, configureApp, createHttpAdapter, createValidationPipe, isDraftSaveRequest, isSendMessageRequest, rejectOversizedJsonBody, verifyJsonBody, } from './app.setup'; /** 본문 파서의 읽기 전 확인을 부른다 — 던진 것을 돌려준다 (던지지 않으면 undefined) */ function verifyFailure(text: string, encoding = 'utf-8'): unknown { try { verifyJsonBody( {} as IncomingMessage, {} as ServerResponse, Buffer.from(text, 'utf8'), encoding, ); } catch (error) { return error; } return undefined; } /** 읽은 뒤의 확인을 부른다 — next 에 넘긴 것 */ function afterParse(body: unknown): unknown[] { const next = jest.fn(); rejectOversizedJsonBody({ body } as Request, {} as Response, next); return next.mock.calls[0] as unknown[]; } const repeat = (unit: string, count: number) => Array.from({ length: count }, () => unit).join(','); describe('JSON 본문의 모양 한도 (JSON_SHAPE_LIMITS — DTO 변환 · 검사 전)', () => { it('읽기 전 확인 — 넘으면 BusinessException VAL_001(상태 400 — body-parser 가 이 상태를 쓴다), 까닭은 로그용 context 에만', () => { const failure = verifyFailure(`{"ids":[${repeat('1', 1_001)}]}`); expect(failure).toBeInstanceOf(BusinessException); expect((failure as BusinessException).code).toBe('VAL_001'); expect((failure as BusinessException).getStatus()).toBe( HttpStatus.BAD_REQUEST, ); // http-errors 는 감싼 오류의 status 를 상태 코드로 쓴다 (body-parser 의 createError(403, err)) expect((failure as { status?: unknown }).status).toBe(400); expect((failure as BusinessException).context).toEqual({ reason: 'json-shape-limit', problem: 'array-too-long', stage: 'before-parse', }); expect(verifyFailure(`{"ids":[${repeat('1', 1_000)}]}`)).toBeUndefined(); }); it('JSON 본문은 UTF-8 만 — 다른 글자 인코딩(바이트로 셀 수 없다)은 작은 본문도 400 VAL_001', () => { // body-parser 는 `utf-` 로 시작하는 charset 을 모두 받아 소문자로 넘긴다 for (const encoding of [ 'utf-16', 'utf-16le', 'utf-16be', 'utf-32', 'utf-7', ]) { const failure = verifyFailure('{"ids":[]}', encoding); expect(failure).toBeInstanceOf(BusinessException); expect((failure as { status?: unknown }).status).toBe(400); expect((failure as BusinessException).code).toBe('VAL_001'); expect((failure as BusinessException).context).toEqual({ reason: 'json-not-utf8', }); } }); it('UTF-8 로 읽히는 다른 이름(utf-_8 등)도 UTF-8 로 보고 모양을 본다 — 이름 글자 그대로 견주면 확인을 건너뛴다', () => { for (const encoding of [ 'utf-8', 'utf-_8', 'utf--8', 'utf-8.', 'utf-8:2026', ]) { expect(verifyFailure('{"ids":[]}', encoding)).toBeUndefined(); expect( ( verifyFailure( `{"ids":[${repeat('1', 1_001)}]}`, encoding, ) as BusinessException ).context, ).toEqual({ reason: 'json-shape-limit', problem: 'array-too-long', stage: 'before-parse', }); } }); it('읽은 뒤 확인 — 한도 안이면 그대로 넘기고(next()), 본문이 없는 요청도 지난다. 넘으면 next(VAL_001)', () => { expect(afterParse({ rules: [], version: 'v' })).toEqual([]); expect(afterParse(undefined)).toEqual([]); const [failure] = afterParse({ ids: Array.from({ length: 5_000 }, () => 1), }); expect(failure).toBeInstanceOf(BusinessException); expect((failure as BusinessException).context).toEqual({ reason: 'json-shape-limit', problem: 'array-too-long', stage: 'after-parse', }); }); it('한도는 계약의 어떤 본문보다 넉넉하다 — 배열 1,000 · 값 10,000 · 깊이 32', () => { expect(JSON_SHAPE_LIMITS).toEqual({ maxArrayLength: 1_000, maxValues: 10_000, maxDepth: 32, }); }); describe('리뷰에서 잰 모양이 400 으로 0.5초 안에 끝난다 (본문 파서의 읽기 전 확인 — 본문은 재기 전에 만든다)', () => { it.each([ [ '필터 rules 에 {"id":N} 8만 개 (1MB)', `{"version":"v","rules":[${Array.from( { length: 80_000 }, (_, index) => `{"id":${index}}`, ).join(',')}]}`, ], [ '규칙 하나의 conditions 에 {} 15만 개', `{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 150_000)}]}]}`, ], [ '규칙 하나의 conditions 에 {} 30만 개 (900KB)', `{"version":"v","rules":[{"id":"r","conditions":[${repeat('{}', 300_000)}]}]}`, ], [ '발송 to 에 {} 139만 개 (4MB 경로의 최대)', `{"subject":"x","body":"x","to":[${repeat('{}', 1_390_000)}]}`, ], [ '여러 통 ids 에 문자열 6만 개', `{"ids":[${Array.from( { length: 60_000 }, (_, index) => `"${index.toString(36)}"`, ).join(',')}]}`, ], [ '로그인 본문에 10만 단 중첩 (600KB — 전에는 DTO 변환의 재귀가 넘쳐 500)', `{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`, ], ])('%s', (_label, text) => { const cpuMs = startCpuTimer(); const failure = verifyFailure(text); expect(cpuMs()).toBeLessThan(500); expect((failure as BusinessException).code).toBe('VAL_001'); }); }); }); describe('전역 DTO 검증 (createValidationPipe — stopAtFirstError)', () => { /** * 모양 한도 안의 가장 무거운 본문들 — 본문 파서 · 모양 확인을 지난 뒤 DTO 변환 · 검사까지 해도 400 이 싸게 난다. * CPU 시간(test/cpu-time.ts) — 지금 0.02~0.08초. 한도 2초는 부하를 견디는 값이다: 2026-10-28 CI 와 같은 Linux 컨테이너에서 * 메모리가 모자라고 테스트가 함께 돌 때 0.59초까지 재졌다(평소의 7배 — 그때 한도 0.5초로 CI 가 실패했다) */ const DTO_CPU_BUDGET_MS = 2_000; const filtersWithManyUnknowns = { version: 'v', rules: Array.from({ length: 50 }, (_, index) => ({ id: `r${index}`, name: '', isEnabled: true, match: 'all', conditions: Array.from({ length: 5 }, () => ({ field: 'subject', operator: 'contains', value: 'x', extra: Array.from({ length: 32 }, () => 1), })), actions: { moveToMailboxId: null, markAsRead: true, flag: false, forwardTo: null, }, })), }; it.each<[string, new () => object, unknown]>([ [ '필터 rules 1,000개 (배열 한도까지)', MailFilterUpdateDto, { version: 'v', rules: Array.from({ length: 1_000 }, (_, index) => ({ id: index })), }, ], [ '규칙 하나의 conditions 1,000개', MailFilterUpdateDto, { version: 'v', rules: [ { id: 'r', conditions: Array.from({ length: 1_000 }, () => ({})) }, ], }, ], [ '필터 50개 × 조건 5개마다 모르는 배열 — 값 약 9,800개 (값 한도 가까이)', MailFilterUpdateDto, filtersWithManyUnknowns, ], [ '발송 to 1,000명', SendMessageDto, { subject: 'x', body: 'x', to: Array.from({ length: 1_000 }, () => ({})), }, ], [ '여러 통 ids 1,000개', MessageDeletionDto, { ids: Array.from({ length: 1_000 }, (_, index) => `m${index}`) }, ], ])( '%s — 모양 확인을 지나도 DTO 검사가 2초 안에 400 (CPU 시간)', async (_label, metatype, body) => { const text = JSON.stringify(body); const pipe = createValidationPipe(); const cpuMs = startCpuTimer(); expect(verifyFailure(text)).toBeUndefined(); const parsed: unknown = JSON.parse(text); expect(afterParse(parsed)).toEqual([]); await expect( pipe.transform(parsed, { type: 'body', metatype }), ).rejects.toBeInstanceOf(BadRequestException); expect(cpuMs()).toBeLessThan(DTO_CPU_BUDGET_MS); }, ); it('검사 결과는 그대로다 — 맞는 본문은 지나고, 한도를 넘거나 모르는 칸이 있으면 400', async () => { const pipe = createValidationPipe(); const valid = { rules: [], version: 'Cbd1Lkq0H3S7mJ5yV0w2qg8qkQvJ8sB7bW2nZ1y3u9E', }; await expect( pipe.transform(valid, { type: 'body', metatype: MailFilterUpdateDto }), ).resolves.toBeInstanceOf(MailFilterUpdateDto); for (const body of [ { ...valid, isActive: true }, { ...valid, rules: Array.from({ length: 51 }, () => ({})) }, { ...valid, version: 'a b' }, ]) { await expect( pipe.transform(body, { type: 'body', metatype: MailFilterUpdateDto }), ).rejects.toBeInstanceOf(BadRequestException); } }); }); describe('임시 저장 요청 판정 (발송과 같은 JSON 본문 4MB)', () => { it('POST /api/drafts 와 PUT /api/drafts/{id} 는 임시 저장 요청이다 — 끝의 / ·쿼리는 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404)', () => { for (const [method, url] of [ ['POST', '/api/drafts'], ['POST', '/API/Drafts/'], ['PUT', '/api/drafts/faaaaabi'], ['PUT', '/api/drafts/faaaaabi/?x=1'], ] as const) { expect(isDraftSaveRequest({ method, url })).toBe(true); } }); it('조회·다른 경로·하위 경로는 1MB 그대로다', () => { for (const [method, url] of [ ['GET', '/api/drafts/faaaaabi'], ['PUT', '/api/drafts'], ['POST', '/api/drafts/faaaaabi'], ['PUT', '/api/drafts/faaaaabi/attachments'], ['PATCH', '/api/drafts/faaaaabi'], ['POST', '/api/draftsx'], ['PUT', '/drafts/faaaaabi'], ] as const) { expect(isDraftSaveRequest({ method, url })).toBe(false); } expect(isDraftSaveRequest({ method: 'POST', url: undefined })).toBe(false); }); }); describe('발송 요청 판정 (발송만 JSON 본문 4MB)', () => { it('POST /api/messages 는 발송 요청이다 — 끝의 / ·쿼리는 라우터처럼 가리지 않고, 본문 한도는 대소문자 변형도 넓게 고른다(그 변형은 라우터에서 404 — 크기 때문에 413 이 되지 않게)', () => { for (const url of [ '/api/messages', '/api/messages/', '/API/Messages', '/api/messages?draft=1', ]) { expect(isSendMessageRequest({ method: 'POST', url })).toBe(true); } }); it('다른 메서드·다른 경로는 1MB 그대로다 — 메일 수정·첨부 업로드·로그인 포함', () => { for (const [method, url] of [ ['PATCH', '/api/messages/eaaaaab'], ['GET', '/api/messages'], ['PUT', '/api/messages'], ['POST', '/api/messages/eaaaaab'], ['POST', '/api/messages/eaaaaab/attachments'], ['POST', '/api/mail-attachments'], ['POST', '/api/auth/login'], ['POST', '/messages'], ['POST', '/api/messagesx'], ] as const) { expect(isSendMessageRequest({ method, url })).toBe(false); } expect(isSendMessageRequest({ method: 'POST', url: undefined })).toBe( false, ); }); }); /** 라우팅 대소문자 시험용 처리기 — 받기 경로와 같은 모양 */ @Controller('probe-routes') class ProbeRoutesController { @Get(':id/large-files/:fileId') download(): string { return 'ok'; } } describe('createHttpAdapter — 라우팅은 대소문자를 가린다 (2026-10-30 인프라 리뷰 High)', () => { const apps: NestExpressApplication[] = []; afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); }); /** 처리기 하나만 둔 앱 — 어댑터를 주면 그것으로, 아니면 Nest 의 기본 어댑터로 */ const createProbeApp = async ( adapter?: ExpressAdapter, ): Promise => { const moduleRef = await Test.createTestingModule({ controllers: [ProbeRoutesController], }).compile(); const app = adapter ? moduleRef.createNestApplication(adapter, { logger: false, }) : moduleRef.createNestApplication({ logger: false, }); apps.push(app); return app; }; it('소문자 경로만 처리기에 닿는다 — 대소문자 변형은 404', async () => { const app = await createProbeApp(createHttpAdapter()); await app.init(); const server = app.getHttpServer(); await request(server).get('/probe-routes/a/large-files/b').expect(200); for (const path of [ '/Probe-Routes/a/large-files/b', '/probe-routes/a/LARGE-FILES/b', '/PROBE-ROUTES/a/large-files/b', ]) { await request(server).get(path).expect(404); } }); it('어댑터를 만든 뒤에 켜면 늦다 — Nest 의 ExpressAdapter 가 만들어질 때 라우터를 이미 만들어 설정 값만 바뀐다', async () => { const app = await createProbeApp(); app.set('case sensitive routing', true); await app.init(); await request(app.getHttpServer()) .get('/Probe-Routes/a/large-files/b') .expect(200); }); it('configureApp 은 대소문자를 가리지 않는 라우터로 만든 앱이면 멈춘다 — 기본 어댑터 · 만든 뒤에 켠 설정 모두', async () => { const plain = await createProbeApp(); expect(() => configureApp(plain)).toThrow(/createHttpAdapter/); const late = await createProbeApp(); late.set('case sensitive routing', true); expect(() => configureApp(late)).toThrow(/createHttpAdapter/); }); });