feat(backend): 직원 인증(쿠키 JWT·DB 세션)과 웹메일 API(Stalwart JMAP 중계)

첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(Stalwart)에 두고 백엔드가 JMAP 으로 중계한다.

- 직원 로그인 = 메일 계정: 엔진으로 비밀번호를 확인한 뒤 HttpOnly 쿠키 JWT 를 발급한다.
  리프레시 세션은 DB 에 해시로 저장하고 1회용으로 회전한다 (이미 쓴 토큰이 오면 세션 폐기)
- 로그인 실패 한도는 엔진에 묻기 전에 원자적으로 차지하고, IPv6 는 /64 로 묶어 센다
- 상태를 바꾸는 요청은 출처(Sec-Fetch-Site·Origin)를 검사하고, 클라이언트 IP 는 TRUST_PROXY 로만 해석한다
- 메일함·목록·읽기·발송·첨부 API: 받은 HTML 은 정화해서 내보내고, 발송은 Idempotency-Key 로 중복을 막는다
- 메일 엔진 관리자 계정은 로그인·대리 접근 대상에서 막는다 (게이트웨이가 관리자까지 대리할 수 있다)
- 에러 코드 AUTH_004·MAIL_001~006 을 추가하고 클라이언트 에러 사전을 다시 생성했다

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 01:31:38 +09:00
parent 238d0ba838
commit 71f2e01d83
115 changed files with 15985 additions and 174 deletions
+563 -34
View File
@@ -1,20 +1,31 @@
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
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 { HttpExceptionFilter } from '@/common/filters/http-exception.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { RequestIdMiddleware } from '@/common/middleware/request-id.middleware';
import { configureApp } from '@/app.setup';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
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 {
@@ -26,7 +37,11 @@ interface ContractBody {
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;
@@ -34,40 +49,133 @@ interface ContractBody {
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: INestApplication<App>;
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],
}).compile();
})
.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();
app = moduleFixture.createNestApplication();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
dataSource = moduleFixture.get(DataSource);
await dataSource.runMigrations();
// main.ts 와 동일한 전역 설정을 적용해야 실제 응답과 같아진다
app.setGlobalPrefix('api');
app.use(new RequestIdMiddleware().use.bind(new RequestIdMiddleware()));
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new TransformInterceptor(app.get(Reflector)));
// 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(app.getHttpServer())
.get('/api/users')
.expect(200);
const res = await request(server).get('/api/users').expect(200);
expect(body(res).success).toBe(true);
expect(body(res).data).toHaveProperty('items');
@@ -75,9 +183,7 @@ describe('응답 계약 (e2e)', () => {
});
it('목록 응답의 pageInfo 는 공유 계약 형태를 지킨다', async () => {
const res = await request(app.getHttpServer())
.get('/api/users')
.expect(200);
const res = await request(server).get('/api/users').expect(200);
const pageInfo = body(res).data?.pageInfo;
@@ -92,9 +198,7 @@ describe('응답 계약 (e2e)', () => {
});
it('없는 리소스는 RES_001 과 traceId 를 반환한다', async () => {
const res = await request(app.getHttpServer())
.get('/api/users/999999')
.expect(404);
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');
@@ -102,7 +206,7 @@ describe('응답 계약 (e2e)', () => {
});
it('검증 실패는 VAL_001 이며 상세 사유를 노출하지 않는다', async () => {
const res = await request(app.getHttpServer())
const res = await request(server)
.post('/api/users')
.send({ email: '잘못된형식', password: '1' })
.expect(400);
@@ -113,7 +217,7 @@ describe('응답 계약 (e2e)', () => {
});
it('DTO 에 없는 필드는 거부된다 (forbidNonWhitelisted)', async () => {
const res = await request(app.getHttpServer())
const res = await request(server)
.get('/api/users?정의되지않은파라미터=1')
.expect(400);
@@ -121,7 +225,7 @@ describe('응답 계약 (e2e)', () => {
});
it('헬스체크는 표준 래퍼 없이 terminus 원본 형식을 반환한다', async () => {
const res = await request(app.getHttpServer()).get('/api/health');
const res = await request(server).get('/api/health');
expect(body(res)).not.toHaveProperty('success');
expect(body(res)).toHaveProperty('status');
@@ -129,8 +233,433 @@ describe('응답 계약 (e2e)', () => {
});
it('모든 응답에 x-request-id 헤더가 붙는다', async () => {
const res = await request(app.getHttpServer()).get('/api/users');
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('형식이 맞는 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);
});
});
/**
* 출처 검사(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);
});
});
});
+16 -1
View File
@@ -11,6 +11,21 @@
},
"testRegex": "test/.*\\.e2e-spec\\.ts$",
"transform": {
"/node_modules/(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/.+\\.js$": [
"ts-jest",
{
"tsconfig": {
"allowJs": true,
"module": "commonjs",
"target": "es2022",
"esModuleInterop": true,
"isolatedModules": true
}
}
],
"^.+\\.(t|j)s$": "ts-jest"
}
},
"transformIgnorePatterns": [
"/node_modules/(?!(htmlparser2|domhandler|domutils|entities|dom-serializer|domelementtype)/)"
]
}