generated from ttipo/project-base
93154ad9a5
- file-storage: /api/file-uploads(만들기 · 16MiB 조각 PUT — Content-Digest · datasync · 같은 조각은 하나만 · 끝내기 · 상태 · 지우기),
/api/file-quota. 보관량(직원 · 서버 · 디스크 여유)은 pg_advisory_xact_lock 안에서 센다. 저장 포트 FILE_BLOB_STORE(지금은 로컬 볼륨).
ClamAV INSTREAM 을 직접 구현했다 — 판정 글은 한 곳(암호 · 한도 → 검사하지 못함, 스트림 크기 한도 → 설정 오류로 검사 중 그대로 + error),
다 보낸 뒤 판정 대기 30분 + 1MB 마다 1초 · TCP keepalive. 검사 차례는 DB pending(동시 2), 정리 cron.
- file-sharing · mail: 메일 한 통에 링크 하나(토큰 256비트 — DB 에는 SHA-256 만). 보낼 때 서버가 본문에 덩이를 넣고(표시는 서버만 —
두 정화기가 사용자 HTML 의 data-ara-* · 주석을 지운다), 링크는 확실한 실패(SendNotStartedException)에서만 거둔다.
공개 /api/public-links/{token} · 파일 받기(Range · 조건부 UPDATE 로 받은 수), 링크 끊기 · 목록 · 읽기 화면의 largeFiles.
- 공개 호스트 미들웨어는 원문 · 라우터 경로 · WHATWG URL · 퍼센트를 푼 원문을 모두 보고(리뷰 — 절대 주소 꼴 · \ 요청이 막기를 지났다),
라우팅은 대소문자를 가린다(createHttpAdapter — /FILES/ 같은 변형이 앞단 상한을 비켜 갔다).
- env: SHARE_PUBLIC_URL(nginx 와 같은 모양 검사 · 운영에서 포털과 같은 호스트면 기동 중단) · MAIL_LARGE_FILE_* · FILE_STORAGE_* · CLAMAV_*.
- 에러 코드 FILE_003 ~ FILE_008 · MAIL_016 — 웹 · 앱 사전 다시 생성. 마이그레이션 둘.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
14 KiB
TypeScript
413 lines
14 KiB
TypeScript
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<NestExpressApplication> => {
|
|
const moduleRef = await Test.createTestingModule({
|
|
controllers: [ProbeRoutesController],
|
|
}).compile();
|
|
const app = adapter
|
|
? moduleRef.createNestApplication<NestExpressApplication>(adapter, {
|
|
logger: false,
|
|
})
|
|
: moduleRef.createNestApplication<NestExpressApplication>({
|
|
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/);
|
|
});
|
|
});
|