generated from ttipo/project-base
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>
This commit is contained in:
+119
-17
@@ -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<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 에 남기지 않는다).
|
||||
@@ -101,8 +143,13 @@ describe('응답 계약 (e2e)', () => {
|
||||
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) =>
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user