Files
arasystem/backend/test/app.e2e-spec.ts
T
ttipo fbfad8d0ca feat(backend): 본문 길이 초과 코드 MAIL_007·본문 잘림 표시·보이지 않는 서식 제거
- 본문이 너무 길면 일반 VAL_001 대신 MAIL_007 을 돌려준다 — 텍스트 100,000자·서식 본문 500,000자 초과(클라이언트와 같은 JS 길이),
  서식 본문의 정화 전 비용 초과, 정화 뒤 2,000,000바이트 초과. 모두 엔진·중복 방지 잠금 전이라 메일은 나가지 않는다.
  클라이언트 에러 사전(frontend·flutter)도 다시 생성했다
- 메일 상세에 isHtmlBodyOmitted(정화 한도를 넘어 HTML 을 비움)와 isBodyTruncated(보여 줄 본문이 읽기 한도에서 잘림)를 더했다
  — 보여 줄 본문은 htmlBody, 그것이 null 이거나 공백뿐이면 textBody 다
- 보내는 서식 본문은 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 지운다 — 붙여 넣거나 인용한 글에 숨긴 글이 그대로 나가지 않게.
  받은 메일 정화는 그대로다

계약: docs/mail/api-contract.md 2절

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

742 lines
28 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 { 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 SLOW_ENGINE_MS = 500;
/**
* 이 테스트가 만드는 세션의 주소 — 끝나면 지운다 (개발 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>();
/** 이 앱의 서명 키로 토큰을 만든다 (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) {
await new Promise((resolve) => setTimeout(resolve, SLOW_ENGINE_MS));
}
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);
}
});
it('같은 주소로 동시에 보낸 틀린 비밀번호 30건 — 엔진 확인은 1번뿐이고 나머지는 처리 중이라 429 REQ_001', async () => {
const results = await Promise.all(
Array.from({ length: 30 }, () =>
loginFrom('203.0.113.6', SLOW_ACCOUNT, 'wrong-password'),
),
);
const codes = results.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);
});
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);
});
});
});