diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts index de24a1d..47ba6d8 100644 --- a/backend/test/app.e2e-spec.ts +++ b/backend/test/app.e2e-spec.ts @@ -3,6 +3,7 @@ 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'; @@ -74,9 +75,50 @@ 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; + +/** 문을 열 조건을 다시 보는 간격 */ +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 에 남기지 않는다). @@ -101,8 +143,13 @@ describe('응답 계약 (e2e)', () => { 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) => @@ -144,9 +191,8 @@ describe('응답 계약 (e2e)', () => { 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)); - } + // 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다 + if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate; return password === CORRECT_PASSWORD ? email : null; }, }) @@ -701,18 +747,74 @@ describe('응답 계약 (e2e)', () => { } }); - 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); + /** + * 한 주소의 확인이 엔진에 가 있는 동안, 같은 주소의 다른 시도는 엔진에 묻지 않고 바로 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); - 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); - }); + 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) {