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; open: () => void } { let open!: () => void; const opened = new Promise((resolve) => { open = resolve; }); return { opened, open }; } /** 조건이 맞거나 기한이 지날 때까지 기다린다 — 맞았는지는 뒤의 단언이 본다 */ async function waitUntil( condition: () => boolean, deadlineMs: number, ): Promise { const deadline = Date.now() + deadlineMs; while (!condition() && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS)); } } /** 서버를 빈 포트로 연다 */ function listenOnAnyPort(server: Server): Promise { return new Promise((resolve) => server.listen(0, () => resolve())); } /** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */ function closeServer(server: Server): Promise { 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(); /** * 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다. * 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다). */ let slowEngineGate: Promise | 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({ bodyParser: false, }); // 요청 추적 ID 도 configureApp 이 맨 앞에 붙인다 (본문 파서 오류에도 traceId) configureApp(app); await app.init(); server = app.getHttpServer(); jwt = app.get(JwtService, { strict: false }); authSettings = app.get(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); }); }); });