Files
arasystem/backend/test/app.e2e-spec.ts
T
ttipo fe8a99e728 test(backend): 동시 로그인 e2e 를 시간에 기대지 않게
"같은 주소로 동시에 보낸 30건 — 엔진 확인은 1번" 테스트는 대역 엔진이 고정 500ms 를 붙잡는 동안 30건이 모두 닿기를 바랐다.
바쁜 기계(이 호스트는 다른 프로젝트의 CI 러너도 돈다)에서 요청이 500ms 넘게 퍼지면 늦은 요청이 엔진을 한 번 더 불러 실패할 수 있었다.

- 고정 대기 대신 문(gate)을 쓴다 — 엔진은 테스트가 열 때까지 답하지 않고, 테스트는 나머지 29건이 REQ_001 로 돌아온 뒤에 연다.
  증명하는 것은 그대로다: 한 주소의 확인이 엔진에 가 있는 동안 같은 주소의 다른 시도는 엔진에 묻지 않고 거절된다
- supertest 는 듣고 있지 않은 서버를 요청마다 열고 닫아, 동시에 보낸 요청이 닫히는 중인 서버에 거절될 수 있었다 —
  이 테스트는 서버를 미리 열고 끝나면 닫는다
- 기한(10초)을 넘기면 멈추지 않고 분명히 실패하고, 문은 언제나 연다 (finally)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:15:52 +09:00

844 lines
33 KiB
TypeScript

import type { NestExpressApplication } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash } from 'crypto';
import { existsSync, unlinkSync } from 'fs';
import type { Server } from 'http';
import { basename, join } from 'path';
import request from 'supertest';
import type { Response } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { AppModule } from '@/app.module';
import { configureApp } from '@/app.setup';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_SUBJECT_LENGTH,
} from '@/modules/mail/mail.constants';
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
*
* ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다).
* 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용.
* 시작할 때 마이그레이션을 실행한다 — 빈 DB(CI)에도 로그인 세션 표가 생긴다 (이미 적용됐으면 건너뛴다).
*
* 메일 엔진(Stalwart)은 띄우지 않는다 — 외부 시스템이므로 포트(MAIL_PROVIDER)를 대역으로 바꾸고,
* 메일 설정(MAIL_SETTINGS)도 고정해 env 와 무관하게(CI 에는 MAIL_* 가 없다) 같은 조건으로 돈다.
* 실제 엔진과의 연동은 개발 스택에서 따로 확인한다 (backend-dev 통합 확인).
*/
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
interface ContractBody {
success?: boolean;
data?: {
items?: unknown[];
pageInfo?: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
email?: string;
name?: string;
fileName?: string;
originalName?: string;
} | null;
error?: { code?: string; message?: string; traceId?: string };
status?: string;
details?: unknown;
}
const body = (res: Response): ContractBody => res.body as ContractBody;
/** Set-Cookie 헤더 중 이름이 같은 것 */
function setCookie(res: Response, name: string): string | undefined {
const raw = res.headers['set-cookie'] as unknown;
const cookies = Array.isArray(raw) ? (raw as string[]) : [];
return cookies.find((cookie) => cookie.startsWith(`${name}=`));
}
/** Set-Cookie 의 값 부분 */
function cookieValue(cookie: string | undefined): string {
return (cookie ?? '').split(';')[0].split('=').slice(1).join('=');
}
const EMPLOYEE = { email: 'ara.kim@e2e.arasystem.test', name: '김아라' };
/** 대역 메일 엔진이 받아 주는 비밀번호 (그 밖의 비밀번호는 틀린 것으로 본다) */
const CORRECT_PASSWORD = 'correct-password-for-e2e';
/** 메일 엔진 관리자 주소 — 로그인할 수 없는 계정 (mail.settings.ts 의 blockedAccounts) */
const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
/** 문을 열 조건을 다시 보는 간격 */
const GATE_POLL_MS = 10;
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
const GATE_DEADLINE_MS = 10_000;
/**
* 문을 쓰는 테스트 하나의 시간 제한 — GATE_DEADLINE_MS 보다 넉넉히 길어야 기한이 먼저 걸린다.
* test/jest-e2e.json 에 testTimeout 이 없어 jest 기본값(5초)이라, 이 테스트만 따로 늘린다.
*/
const GATED_TEST_TIMEOUT_MS = 20_000;
/** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */
function createGate(): { readonly opened: Promise<void>; open: () => void } {
let open!: () => void;
const opened = new Promise<void>((resolve) => {
open = resolve;
});
return { opened, open };
}
/** 조건이 맞거나 기한이 지날 때까지 기다린다 — 맞았는지는 뒤의 단언이 본다 */
async function waitUntil(
condition: () => boolean,
deadlineMs: number,
): Promise<void> {
const deadline = Date.now() + deadlineMs;
while (!condition() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS));
}
}
/** 서버를 빈 포트로 연다 */
function listenOnAnyPort(server: Server): Promise<void> {
return new Promise((resolve) => server.listen(0, () => resolve()));
}
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
/**
* 이 테스트가 만드는 세션의 주소 — 끝나면 지운다 (개발 DB 에 남기지 않는다).
* 개발 메일 도메인(arasystem.test)과 **다른** 도메인을 쓴다 — 같으면 개발 중인 사람의 실제 세션까지 지워진다.
*/
const TEST_DOMAIN_PATTERN = '%@e2e.arasystem.test';
/** e2e 전용 메일 설정 — 대역 엔진과 함께 쓴다 (주소 정책만 의미가 있다) */
const TEST_MAIL_SETTINGS: MailSettings = {
jmapBaseUrl: null,
domain: 'e2e.arasystem.test',
gatewayUser: 'mail-gateway@e2e.arasystem.test',
gatewayPassword: null,
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set([ADMIN_ACCOUNT]),
};
describe('응답 계약 (e2e)', () => {
let app: NestExpressApplication;
let server: App;
let jwt: JwtService;
let authSettings: AuthSettings;
let dataSource: DataSource;
/** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) — 확인이 엔진에 도착한 때 는다 */
const engineChecks = new Map<string, number>();
/**
* 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다.
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
*/
let slowEngineGate: Promise<void> | null = null;
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
const sign = (payload: object, expiresIn: number) =>
jwt.sign(payload, {
secret: authSettings.jwtSecret,
algorithm: 'HS256',
expiresIn,
});
/** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */
const login = async (email: string) => {
const res = await request(server)
.post('/api/auth/login')
.send({ email, password: CORRECT_PASSWORD })
.expect(200);
return {
access: cookieValue(setCookie(res, 'access_token')),
refresh: cookieValue(setCookie(res, 'refresh_token')),
};
};
const refreshWith = (refreshToken: string) =>
request(server)
.post('/api/auth/refresh')
.set('Cookie', `refresh_token=${refreshToken}`);
/** 새 리프레시 쿠키 값 */
const rotated = (res: Response) =>
cookieValue(setCookie(res, 'refresh_token'));
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MAIL_PROVIDER)
.useValue({
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다
getProfile: (email: string) =>
Promise.resolve({ email, name: EMPLOYEE.name }),
verifyCredentials: async (email: string, password: string) => {
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
return password === CORRECT_PASSWORD ? email : null;
},
})
.overrideProvider(MAIL_SETTINGS)
.useValue(TEST_MAIL_SETTINGS)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
dataSource = moduleFixture.get(DataSource);
await dataSource.runMigrations();
// main.ts 와 **같은 함수**로 전역 설정을 적용한다 — 실제 응답과 같아진다
app = moduleFixture.createNestApplication<NestExpressApplication>({
bodyParser: false,
});
// 요청 추적 ID 도 configureApp 이 맨 앞에 붙인다 (본문 파서 오류에도 traceId)
configureApp(app);
await app.init();
server = app.getHttpServer();
jwt = app.get(JwtService, { strict: false });
authSettings = app.get<AuthSettings>(AUTH_SETTINGS, { strict: false });
});
afterAll(async () => {
await dataSource.query(
'DELETE FROM auth_sessions WHERE user_email LIKE $1',
[TEST_DOMAIN_PATTERN],
);
await app.close();
});
it('성공 응답은 { success, data } 로 감싸진다', async () => {
const res = await request(server).get('/api/users').expect(200);
expect(body(res).success).toBe(true);
expect(body(res).data).toHaveProperty('items');
expect(body(res).data).toHaveProperty('pageInfo');
});
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
const res = await request(server).get('/api/users').expect(200);
const pageInfo = body(res).data?.pageInfo;
expect(Object.keys(pageInfo ?? {}).sort()).toEqual([
'hasNext',
'nextCursor',
'total',
]);
expect(typeof pageInfo?.hasNext).toBe('boolean');
expect(pageInfo?.nextCursor).toBeNull();
expect(typeof pageInfo?.total).toBe('number');
});
it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => {
const res = await request(server).get('/api/users/999999').expect(404);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('RES_001');
expect(typeof body(res).error?.traceId).toBe('string');
});
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
const res = await request(server)
.post('/api/users')
.send({ email: '잘못된형식', password: '1' })
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
// class-validator 의 개별 메시지가 그대로 새어나가면 안 된다
expect(JSON.stringify(body(res))).not.toContain('must be an email');
});
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
const res = await request(server)
.get('/api/users?정의되지않은파라미터=1')
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => {
const res = await request(server).get('/api/health');
expect(body(res)).not.toHaveProperty('success');
expect(body(res)).toHaveProperty('status');
expect(body(res)).toHaveProperty('details');
});
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
const res = await request(server).get('/api/users');
expect(typeof res.headers['x-request-id']).toBe('string');
});
it('본문 파서 단계의 실패(413)에도 traceId 가 붙는다 — 요청 ID 미들웨어가 가장 앞에 있다', async () => {
const res = await request(server)
.post('/api/auth/login')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ email: 'x', password: 'y'.repeat(1_100_000) }))
.expect(413);
expect(body(res).error?.code).toBe('FILE_002');
expect(typeof body(res).error?.traceId).toBe('string');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
});
it('발송(POST /api/messages)만 JSON 본문 4MB 까지 받는다 — 2MB 발송 본문은 파서를 지나 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
const parsed = await request(server)
.post('/api/messages')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
.expect(401);
expect(body(parsed).error?.code).toBe('AUTH_001');
const tooLarge = await request(server)
.post('/api/messages')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
.expect(413);
expect(body(tooLarge).error?.code).toBe('FILE_002');
expect(body(tooLarge).error?.traceId).toBe(
tooLarge.headers['x-request-id'],
);
});
it('다른 API 는 그대로 1MB — 같은 2MB 본문이 로그인·메일 수정(PATCH /api/messages/:id)에서는 413 FILE_002', async () => {
const twoMegabytes = JSON.stringify({
email: 'x',
password: 'y'.repeat(2_000_000),
});
for (const [method, url] of [
['post', '/api/auth/login'],
['patch', '/api/messages/eaaaaab'],
] as const) {
const res = await request(server)
[method](url)
.set('Content-Type', 'application/json')
.send(twoMegabytes)
.expect(413);
expect(body(res).error?.code).toBe('FILE_002');
}
});
it('형식이 맞는 x-request-id 는 이어받고, 그 밖의 값은 새로 발급한다', async () => {
const kept = await request(server)
.get('/api/users/999999')
.set('x-request-id', 'nginx-req_2026.001')
.expect(404);
expect(kept.headers['x-request-id']).toBe('nginx-req_2026.001');
expect(body(kept).error?.traceId).toBe('nginx-req_2026.001');
const replaced = await request(server)
.get('/api/users/999999')
.set('x-request-id', 'bad id "with" spaces')
.expect(404);
expect(replaced.headers['x-request-id']).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
expect(body(replaced).error?.traceId).toBe(
replaced.headers['x-request-id'],
);
});
describe('인증 (쿠키 기반)', () => {
it('로그인 본문 형식 오류는 VAL_001 이고 쿠키를 굽지 않는다', async () => {
const res = await request(server)
.post('/api/auth/login')
.send({ email: '이메일아님', password: '' })
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
expect(res.headers['set-cookie']).toBeUndefined();
});
it('JSON 이 아닌 본문(form)은 받지 않는다 — 다른 사이트의 form 으로 로그인할 수 없다', async () => {
const res = await request(server)
.post('/api/auth/login')
.type('form')
.send({ email: EMPLOYEE.email, password: 'whatever' })
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
});
it('메일 관리자 주소는 비밀번호가 맞아도 401 AUTH_004 이고 메일 엔진에 묻지 않는다 (대소문자 무관)', async () => {
for (const email of [ADMIN_ACCOUNT, ADMIN_ACCOUNT.toUpperCase()]) {
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '203.0.113.20')
.send({ email, password: CORRECT_PASSWORD })
.expect(401);
expect(body(res).error?.code).toBe('AUTH_004');
expect(cookieValue(setCookie(res, 'access_token'))).toBe('');
}
expect(engineChecks.has(ADMIN_ACCOUNT)).toBe(false);
});
it('쿠키 없이 /auth/me 는 401 AUTH_001', async () => {
const res = await request(server).get('/api/auth/me').expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('리프레시 쿠키가 없으면 401 AUTH_001 이고 두 쿠키를 지운다', async () => {
const res = await request(server).post('/api/auth/refresh').expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
expect(setCookie(res, 'access_token')).toMatch(
/Path=\/api;.*Expires=Thu, 01 Jan 1970/,
);
expect(setCookie(res, 'refresh_token')).toMatch(
/Path=\/api\/auth;.*Expires=Thu, 01 Jan 1970/,
);
});
it('액세스 쿠키 없이 리프레시 쿠키만으로 /auth/refresh 가 동작하고 두 쿠키를 회전한다 (세션 복원)', async () => {
const { refresh: refreshToken } = await login(EMPLOYEE.email);
const res = await refreshWith(refreshToken).expect(200);
expect(body(res)).toEqual({ success: true, data: EMPLOYEE });
// 토큰은 본문에 없다
expect(JSON.stringify(body(res))).not.toContain('eyJ');
const access = setCookie(res, 'access_token');
const refresh = setCookie(res, 'refresh_token');
expect(access).toMatch(/Max-Age=\d+/);
expect(access).toMatch(/Path=\/api;/);
expect(access).toMatch(/HttpOnly/);
expect(access).toMatch(/SameSite=Lax/);
expect(refresh).toMatch(/Path=\/api\/auth;/);
expect(refresh).toMatch(/HttpOnly/);
expect(refresh).toMatch(/SameSite=Strict/);
expect(cookieValue(refresh)).not.toBe(refreshToken);
// 새 액세스 쿠키로 세션이 복원된다
const me = await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${cookieValue(access)}`)
.expect(200);
expect(body(me).data).toEqual(EMPLOYEE);
});
it('만료된 액세스 쿠키가 함께 와도 /auth/refresh 는 리프레시 쿠키로 동작한다', async () => {
const { refresh: refreshToken } = await login(EMPLOYEE.email);
const expiredAccess = sign(
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
-60,
);
await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${expiredAccess}`)
.expect(401);
await request(server)
.post('/api/auth/refresh')
.set('Cookie', [
`access_token=${expiredAccess}`,
`refresh_token=${refreshToken}`,
])
.expect(200);
});
it('리프레시 토큰은 1회용 — 이미 쓴 토큰이 다시 오면 세션 전체가 끊긴다 (최신 토큰도 401)', async () => {
const { refresh: first } = await login(EMPLOYEE.email);
const second = rotated(await refreshWith(first).expect(200));
const third = rotated(await refreshWith(second).expect(200));
const reused = await refreshWith(first).expect(401);
expect(body(reused).error?.code).toBe('AUTH_001');
// 쿠키도 지운다
expect(setCookie(reused, 'refresh_token')).toMatch(
/Expires=Thu, 01 Jan 1970/,
);
const latest = await refreshWith(third).expect(401);
expect(body(latest).error?.code).toBe('AUTH_001');
});
it('회전 직후의 바로 이전 토큰(다른 탭의 동시 갱신)은 세션을 끊지 않고 새 쿠키 없이 200', async () => {
const { refresh: first } = await login(EMPLOYEE.email);
const second = rotated(await refreshWith(first).expect(200));
const late = await refreshWith(first).expect(200);
expect(body(late).data).toEqual(EMPLOYEE);
expect(late.headers['set-cookie']).toBeUndefined();
// 이긴 쪽의 새 토큰은 그대로 쓸 수 있다
await refreshWith(second).expect(200);
});
it('로그아웃하면 서버의 세션이 끊겨 같은 리프레시 쿠키로 갱신할 수 없다', async () => {
const { refresh: refreshToken } = await login(EMPLOYEE.email);
await request(server)
.post('/api/auth/logout')
.set('Cookie', `refresh_token=${refreshToken}`)
.expect(200);
const res = await refreshWith(refreshToken).expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('세션 표에는 토큰 원문이 아니라 해시(SHA-256)만 남는다', async () => {
const { refresh: refreshToken } = await login(EMPLOYEE.email);
const hash = createHash('sha256').update(refreshToken).digest('hex');
const rows: { user_email: string }[] = await dataSource.query(
'SELECT user_email FROM auth_sessions WHERE token_hash = $1',
[hash],
);
expect(rows).toEqual([{ user_email: EMPLOYEE.email }]);
});
it('리프레시 토큰을 액세스 쿠키에 넣으면 통하지 않는다 (typ 구분)', async () => {
const refreshToken = sign({ sub: EMPLOYEE.email, typ: 'refresh' }, 600);
const res = await request(server)
.get('/api/auth/me')
.set('Cookie', `access_token=${refreshToken}`)
.expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('로그아웃은 항상 200 (data: null) 이고 두 쿠키를 지운다', async () => {
const res = await request(server).post('/api/auth/logout').expect(200);
expect(body(res)).toEqual({ success: true, data: null });
expect(setCookie(res, 'access_token')).toMatch(
/Expires=Thu, 01 Jan 1970/,
);
expect(setCookie(res, 'refresh_token')).toMatch(
/Expires=Thu, 01 Jan 1970/,
);
});
});
describe('메일 (로그인 필요)', () => {
it.each([
['get', '/api/mailboxes'],
['get', '/api/mail-quota'],
['get', '/api/messages?mailboxId=a'],
['get', '/api/messages/eaaaaab'],
['post', '/api/messages'],
['post', '/api/mail-attachments'],
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
['post', '/api/files'],
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
const res = await request(server)[method](path).expect(401);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('AUTH_001');
expect(typeof body(res).error?.traceId).toBe('string');
});
it('로그인 없이 보낸 업로드는 파일을 읽기 전에 막힌다 (가드 → multer 순서)', async () => {
const res = await request(server)
.post('/api/files')
.attach('file', Buffer.from('png'), 'photo.png')
.expect(401);
expect(body(res).error?.code).toBe('AUTH_001');
});
it('로그인하면 업로드할 수 있고, 한글 파일명이 깨지지 않는다 (multer defParamCharset utf8)', async () => {
const accessToken = sign(
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
600,
);
const res = await request(server)
.post('/api/files')
.set('Cookie', `access_token=${accessToken}`)
// 브라우저처럼 파일명을 UTF-8 그대로 싣는다
.attach('file', Buffer.from([0x89, 0x50, 0x4e, 0x47]), {
filename: '한글 사진.png',
contentType: 'image/png',
})
.expect(201);
const saved = body(res).data;
expect(saved?.originalName).toBe('한글 사진.png');
// 저장 파일명은 서버가 만든 UUID + 확장자 (클라이언트 이름을 경로에 쓰지 않는다)
expect(saved?.fileName).toMatch(/^[0-9a-f-]{36}\.png$/);
// 테스트가 만든 파일을 지운다
const stored = join(UPLOAD_DIR, basename(saved?.fileName ?? ''));
if (saved?.fileName && existsSync(stored)) unlinkSync(stored);
});
it('발송 본문이 너무 길면 400 MAIL_007 — 형식 검증이 아니라 서비스가 JS 문자열 길이(UTF-16)로 센다, 제목은 그대로 VAL_001', async () => {
const accessToken = sign(
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
600,
);
// 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다)
const send = (overrides: object) =>
request(server)
.post('/api/messages')
.set('Cookie', `access_token=${accessToken}`)
.send({
to: [{ name: null, email: 'doyun.lee@e2e.arasystem.test' }],
subject: '제목',
body: '본문',
...overrides,
});
for (const overrides of [
// 이모지 50,001개 = 100,002자 — 예전 형식 검증(MaxLength)은 50,001자로 세어 통과시켰다
{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) },
{ bodyHtml: 'x'.repeat(MAX_BODY_HTML_LENGTH + 1) },
]) {
const res = await send(overrides).expect(400);
expect(body(res).error?.code).toBe('MAIL_007');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
}
const longSubject = await send({
subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1),
}).expect(400);
expect(body(longSubject).error?.code).toBe('VAL_001');
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
*/
describe('출처 검사 (상태 변경 요청)', () => {
const EVIL_ORIGIN = 'https://evil.example';
it('다른 사이트(Sec-Fetch-Site: cross-site)에서 온 POST 는 403 AUTH_003', async () => {
const res = await request(server)
.post('/api/auth/logout')
.set('Sec-Fetch-Site', 'cross-site')
.set('Origin', EVIL_ORIGIN)
.expect(403);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('AUTH_003');
expect(typeof body(res).error?.traceId).toBe('string');
});
it.each([
['post', '/api/auth/login'],
['post', '/api/auth/refresh'],
['post', '/api/messages'],
['patch', '/api/messages/eaaaaab'],
['delete', '/api/messages/eaaaaab'],
['post', '/api/mail-attachments'],
] as const)(
'Sec-Fetch-Site 없이 허용 목록에 없는 Origin 이면 %s %s 도 403 AUTH_003 (로그인 검사보다 먼저)',
async (method, path) => {
const res = await request(server)
[method](path)
.set('Origin', EVIL_ORIGIN)
.expect(403);
expect(body(res).error?.code).toBe('AUTH_003');
},
);
it('샌드박스 iframe 의 Origin: null 도 거부한다', async () => {
const res = await request(server)
.post('/api/auth/logout')
.set('Origin', 'null')
.expect(403);
expect(body(res).error?.code).toBe('AUTH_003');
});
it('출처 정보가 없는 요청(브라우저가 아닌 클라이언트)은 통과한다', async () => {
await request(server).post('/api/auth/logout').expect(200);
});
it('같은 주소의 화면(same-origin)과 사용자가 직접 연 요청(none)은 통과한다 — Origin 이 무엇이든', async () => {
for (const site of ['same-origin', 'none']) {
await request(server)
.post('/api/auth/logout')
.set('Sec-Fetch-Site', site)
.set('Origin', 'http://localhost:5173')
.expect(200);
}
});
it('Sec-Fetch-Site 를 보내지 않는 브라우저는 허용 목록의 Origin 이면 통과한다 (개발 기본값)', async () => {
await request(server)
.post('/api/auth/logout')
.set('Origin', 'http://localhost:5173')
.expect(200);
});
it('조회(GET)는 다른 사이트에서 와도 검사하지 않는다', async () => {
await request(server)
.get('/api/users')
.set('Sec-Fetch-Site', 'cross-site')
.set('Origin', EVIL_ORIGIN)
.expect(200);
});
});
/**
* 로그인 실패 한도 (계약 1절) — 성공은 세지 않고, 같은 IP 1분 실패 20회 · 같은 주소 15분 실패 10회를 넘으면 REQ_001.
* 클라이언트 IP 는 프록시 한 단계 뒤에서 읽으므로(TRUST_PROXY 기본값 1) X-Forwarded-For 로 시나리오마다 IP 를 나눈다.
*/
describe('로그인 실패 한도', () => {
const loginFrom = (ip: string, email: string, password: string) =>
request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', ip)
.send({ email, password });
it('성공한 로그인은 세지 않는다 — 같은 IP 에서 연달아 성공해도 막히지 않는다', async () => {
for (let i = 0; i < 8; i += 1) {
const res = await loginFrom(
'203.0.113.1',
EMPLOYEE.email,
CORRECT_PASSWORD,
);
expect(res.status).toBe(200);
}
});
it('같은 주소로 실패 10회를 넘으면 비밀번호가 맞아도 429 REQ_001 — 없는 주소도 똑같다', async () => {
// 주소마다 IP 를 나눠 IP 한도(20회)와 섞이지 않게 한다
const cases = [
['locked.user@e2e.arasystem.test', '203.0.113.2'],
['nobody.here@e2e.arasystem.test', '203.0.113.3'],
] as const;
for (const [email, ip] of cases) {
for (let i = 0; i < 10; i += 1) {
const res = await loginFrom(ip, email, 'wrong-password');
expect(body(res).error?.code).toBe('AUTH_004');
}
const locked = await loginFrom(ip, email, CORRECT_PASSWORD).expect(429);
expect(body(locked).error?.code).toBe('REQ_001');
// 같은 IP 의 다른 주소는 그대로 로그인된다 (그 IP 의 실패는 10회뿐)
await loginFrom(
ip,
'doyun.lee@e2e.arasystem.test',
CORRECT_PASSWORD,
).expect(200);
}
});
/**
* 한 주소의 확인이 엔진에 가 있는 동안, 같은 주소의 다른 시도는 엔진에 묻지 않고 바로 REQ_001 이다
* (login-attempt.service.ts 의 inFlight — 한 주소는 한 번에 하나만 엔진에 묻는다).
*
* 대역 엔진은 첫 확인을 문으로 붙잡아 두고, 나머지 29건이 모두 돌아온 것을 본 뒤에 연다 — 시간과 상관없이 늘 같은 순서다.
* 예전에는 엔진이 500ms 동안만 붙잡았는데, 바쁜 호스트(다른 프로젝트의 CI 러너와 함께 돈다)에서 30건이 500ms 넘게
* 퍼지면 늦게 온 요청이 두 번째 확인을 시작해 실패했다 (2026-10-27).
*
* 서버도 이 테스트 동안 미리 열어 둔다. supertest(7.2)는 서버가 듣고 있지 않으면 첫 요청이 서버를 열고 자기 응답 뒤에 닫는데,
* 닫기는 모든 연결이 끝나야 끝나고 그사이 새 연결은 거절된다 — 붙잡힌 요청이 첫 요청이 아니면 첫 요청의 응답이 문이 열릴 때까지
* 돌아오지 않고(29건이 오지 않는다), 늦게 연결한 요청은 거절될 수 있다.
*/
it(
'같은 주소로 동시에 보낸 틀린 비밀번호 30건 — 엔진 확인은 1번뿐이고 나머지는 처리 중이라 429 REQ_001',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const gate = createGate();
slowEngineGate = gate.opened;
const arrived: Response[] = [];
const pending = Array.from({ length: 30 }, () =>
loginFrom('203.0.113.6', SLOW_ACCOUNT, 'wrong-password').then(
(res) => {
arrived.push(res);
return res;
},
),
);
try {
// 문이 닫힌 동안: 첫 확인은 엔진에서 기다리고, 나머지 29건은 엔진에 묻지 않고 돌아와야 한다
await waitUntil(
() =>
(arrived.length >= 29 &&
(engineChecks.get(SLOW_ACCOUNT) ?? 0) >= 1) ||
arrived.length === 30,
GATE_DEADLINE_MS,
);
expect({
arrived: arrived.length,
engineChecks: engineChecks.get(SLOW_ACCOUNT),
codes: arrived.map((res) => body(res).error?.code),
}).toEqual({
arrived: 29,
engineChecks: 1,
codes: Array.from({ length: 29 }, () => 'REQ_001'),
});
} finally {
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트·앱 종료가 멈추지 않게.
// 미리 연 서버는 닫아 다른 테스트는 예전처럼 요청마다 supertest 가 연다
gate.open();
slowEngineGate = null;
await Promise.allSettled(pending);
if (startedServer) await closeServer(httpServer);
}
// 문을 연 뒤: 붙잡혀 있던 첫 확인만 엔진의 답(틀린 비밀번호)으로 끝난다
const codes = (await Promise.all(pending)).map(
(res) => body(res).error?.code,
);
expect(engineChecks.get(SLOW_ACCOUNT)).toBe(1);
expect(codes.filter((code) => code === 'AUTH_004')).toHaveLength(1);
expect(codes.filter((code) => code === 'REQ_001')).toHaveLength(29);
},
GATED_TEST_TIMEOUT_MS,
);
it('같은 IP 에서 실패 20회를 넘으면 처음 보는 주소도 429 REQ_001 — 다른 IP 는 영향 없음', async () => {
for (let i = 0; i < 20; i += 1) {
const res = await loginFrom(
'203.0.113.4',
`guess${i}@e2e.arasystem.test`,
'wrong-password',
);
expect(body(res).error?.code).toBe('AUTH_004');
}
const blocked = await loginFrom(
'203.0.113.4',
'seojun.park@e2e.arasystem.test',
CORRECT_PASSWORD,
).expect(429);
expect(body(blocked).error?.code).toBe('REQ_001');
await loginFrom(
'203.0.113.5',
'seojun.park@e2e.arasystem.test',
CORRECT_PASSWORD,
).expect(200);
});
});
});