generated from ttipo/project-base
71f2e01d83
첫 사내 모듈인 웹메일의 백엔드다. 메일 엔진은 외부(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>
666 lines
25 KiB
TypeScript
666 lines
25 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 { 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('형식이 맞는 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);
|
|
});
|
|
});
|
|
});
|