generated from ttipo/project-base
7ce234750d
본문 크기 한도 안에서도 작은 객체 수만 개면 DTO 변환 · 검사가 서버를 수 초 멈추고 수백 MB 를 썼다(리뷰 측정: 1MB 필터 본문 7.8초 · +329MB, 깊은 중첩은 500 SYS_001, 4MB 발송 본문은 한 건으로 메모리 초과 추정). DTO 의 개수 한도로는 막지 못한다 — 변환은 항목마다 객체를 만들고, 검사는 한도에 걸려도 항목마다 중첩 검사를 한다. - 모든 JSON 본문을 DTO 보다 앞에서 잰다(배열 1,000 · 값 10,000 · 깊이 32). 읽기 전에 UTF-8 바이트를 한 번 훑고, 읽은 뒤에 한 번 더 본다. 넘으면 400 VAL_001 - JSON 은 UTF-8 만 받는다. 다른 utf-* 본문은 바이트로 셀 수 없다 - 전역 파이프에 stopAtFirstError 를 켠다. 항목 수의 제곱이던 ArrayUnique 대신 Set 으로 보는 ArrayDistinct 를 쓴다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3459 lines
134 KiB
TypeScript
3459 lines
134 KiB
TypeScript
import type { NestExpressApplication } from '@nestjs/platform-express';
|
|
import { JwtService } from '@nestjs/jwt';
|
|
import { Test, TestingModule } from '@nestjs/testing';
|
|
import { createHash, randomUUID } from 'crypto';
|
|
import { existsSync, unlinkSync } from 'fs';
|
|
import { get as httpGet, type IncomingMessage, type Server } from 'http';
|
|
import { basename, join } from 'path';
|
|
import { Readable } from 'stream';
|
|
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 { BusinessException } from '@/common/errors/business.exception';
|
|
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
|
import {
|
|
MAX_BODY_HTML_LENGTH,
|
|
MAX_BODY_LENGTH,
|
|
MAX_BULK_MESSAGE_IDS,
|
|
MAX_SEARCH_LENGTH,
|
|
MAX_SEARCH_WORDS,
|
|
MAX_SUBJECT_LENGTH,
|
|
MAX_VACATION_SUBJECT_LENGTH,
|
|
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
|
|
MAX_VACATION_TEXT_LENGTH,
|
|
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
|
|
} from '@/modules/mail/mail.constants';
|
|
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
|
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
|
import type {
|
|
Correspondence,
|
|
DraftContent,
|
|
DraftSource,
|
|
Mailbox,
|
|
MailboxBulkResult,
|
|
MailboxCreate,
|
|
MailboxDeletion,
|
|
MailFilterRule,
|
|
MailFilterSet,
|
|
MailFilterUpdate,
|
|
MailVacation,
|
|
MailWatch,
|
|
MailWatchListener,
|
|
MessageDeletionResult,
|
|
MessageListQuery,
|
|
MessageSource,
|
|
MessagesPatchResult,
|
|
MessageSummary,
|
|
MessageUpdate,
|
|
OutgoingMessage,
|
|
PasswordChangeOutcome,
|
|
RecipientRejectionReason,
|
|
SavedDraft,
|
|
SentMessage,
|
|
} from '@/modules/mail/mail.types';
|
|
import {
|
|
SIGNATURE_IMAGES_PER_SIGNATURE,
|
|
SIGNATURE_IMAGES_STORED_MAX,
|
|
SIGNATURE_IMAGE_DIR,
|
|
SIGNATURE_IMAGE_MAX_BYTES,
|
|
} from '@/modules/mail-signature/mail-signature.constants';
|
|
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
|
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
|
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
|
import { pngBytes } from './image-fixtures';
|
|
|
|
/**
|
|
* 세 플랫폼이 공유하는 응답 계약을 실제 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;
|
|
|
|
/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */
|
|
const dataOf = <T>(res: Response): T => (res.body as { data: T }).data;
|
|
|
|
/** 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';
|
|
|
|
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
|
|
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
|
|
|
|
/** 대역 메일 엔진이 돌려주는 새 메일 확인 값 */
|
|
const MAIL_STATE = 'c2Iy.c2Fq';
|
|
|
|
/** 대역 메일 엔진이 "없는 메일함"으로 답하는 이동 대상 (404 RES_001 경로) */
|
|
const MISSING_MAILBOX = 'nomailbox';
|
|
|
|
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
|
|
const GONE_MESSAGE = 'gonemessage';
|
|
|
|
/** 대역 메일 엔진이 "엔진 규칙에 어긋난다"고 답하는 새 비밀번호 (400 VAL_001 경로) */
|
|
const ENGINE_REJECTED_PASSWORD = 'engine-rejects-this-one';
|
|
|
|
/** 대역 메일 엔진이 "지금 비밀번호로 들어갔지만 바꾸기는 거절했다"고 답하는 새 비밀번호 (400 AUTH_005 — 실패로 세지 않는다) */
|
|
const ENGINE_REFUSED_PASSWORD = 'engine-refuses-this-change';
|
|
|
|
/** 대역 메일 엔진의 비밀번호 바꾸기가 결과를 알 수 없게 끝나는 새 비밀번호 (503 MAIL_001 — 다른 세션은 끊어 둔다) */
|
|
const ENGINE_UNKNOWN_PASSWORD = 'engine-outcome-unknown';
|
|
|
|
/** 대역 메일 엔진이 "같은 상위 안에 같은 이름이 있다"고 답하는 메일함 이름 (409 MAIL_009 경로) */
|
|
const TAKEN_MAILBOX_NAME = '이미 있는 이름';
|
|
|
|
/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */
|
|
const SLOW_MAILBOX = 'slowbox';
|
|
|
|
/** 대역 메일 엔진이 실시간 알림의 처음 연결을 받지 않는 주소 (503 MAIL_001 경로) */
|
|
const EVENTS_ENGINE_DOWN = 'events.down@e2e.arasystem.test';
|
|
|
|
/**
|
|
* 대역 메일 엔진이 발송 때 받지 않는 받는 사람과 그 까닭 (계약 2절 "받는 사람별 결과").
|
|
* SMTP 응답의 분류 · 모두 거절일 때 보낸 사본 지우기는 jmap-mail.provider.spec.ts · delivery-status.spec.ts 가 본다
|
|
*/
|
|
const NOBODY = 'nobody@e2e.arasystem.test';
|
|
const BUSY = 'busy@e2e.arasystem.test';
|
|
const ENGINE_REJECTIONS = new Map<string, RecipientRejectionReason>([
|
|
[NOBODY, 'not-found'],
|
|
[BUSY, 'temporary'],
|
|
]);
|
|
|
|
/** 대역 메일 엔진이 "만든 엔진 규칙이 엔진 한도를 넘는다"고 답하는 필터의 찾을 글 (400 MAIL_013 경로) */
|
|
const ENGINE_TOO_LARGE_FILTER_VALUE = 'engine-says-too-large';
|
|
|
|
/** 대역 메일 엔진의 메일함 — 필터가 옮길 수 없는 보낸편지함(e) · 임시보관함(d) 확인용 */
|
|
const E2E_MAILBOXES: Mailbox[] = (
|
|
[
|
|
['a', 'inbox'],
|
|
['b', 'trash'],
|
|
['c', 'junk'],
|
|
['d', 'drafts'],
|
|
['e', 'sent'],
|
|
['j', null],
|
|
] as const
|
|
).map(([id, role]) => ({
|
|
id,
|
|
name: id,
|
|
role,
|
|
parentId: null,
|
|
sortOrder: 0,
|
|
totalCount: 0,
|
|
unreadCount: 0,
|
|
}));
|
|
|
|
/** 대역 엔진의 규칙 지문 — 실제 지문은 jmap/filter-source.ts (여기서는 같은 규칙이면 같은 값이면 된다) */
|
|
const fakeFilterVersion = (rules: readonly MailFilterRule[]) =>
|
|
createHash('sha256').update(JSON.stringify(rules)).digest('base64url');
|
|
|
|
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
|
|
const NEVER_CONFIGURED_VACATION: MailVacation = {
|
|
isEnabled: false,
|
|
fromDate: null,
|
|
toDate: null,
|
|
subject: '',
|
|
textBody: '',
|
|
};
|
|
|
|
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
|
|
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
|
|
const RAW_MESSAGE =
|
|
'From: doyun.lee@e2e.arasystem.test\r\nSubject: Re: 견적서/10월\r\n\r\n견적서를 보냅니다.\r\n';
|
|
|
|
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
|
const PROFILE_NAMES = new Map([
|
|
['colleague.a@e2e.arasystem.test', '가나다'],
|
|
['colleague.b@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<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()));
|
|
}
|
|
|
|
/** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */
|
|
interface EventStreamClient {
|
|
readonly response: IncomingMessage;
|
|
/** 지금까지 받은 글자 */
|
|
received(): string;
|
|
/** 클라이언트가 끊는다 */
|
|
close(): void;
|
|
}
|
|
|
|
/**
|
|
* 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다.
|
|
* 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다
|
|
*/
|
|
function openEventStream(
|
|
server: Server,
|
|
cookie: string,
|
|
): Promise<EventStreamClient> {
|
|
const address = server.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
return new Promise((resolve, reject) => {
|
|
const request = httpGet(
|
|
{
|
|
host: '127.0.0.1',
|
|
port,
|
|
path: '/api/mail-events',
|
|
headers: { cookie, accept: 'text/event-stream' },
|
|
agent: false,
|
|
},
|
|
(response) => {
|
|
let text = '';
|
|
response.setEncoding('utf8');
|
|
response.on('data', (chunk: string) => {
|
|
text += chunk;
|
|
});
|
|
// 끊은 뒤의 오류(ECONNRESET 등)는 테스트와 상관없다
|
|
response.on('error', () => undefined);
|
|
resolve({
|
|
response,
|
|
received: () => text,
|
|
close: () => request.destroy(),
|
|
});
|
|
},
|
|
);
|
|
request.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
|
|
function closeServer(server: Server): Promise<void> {
|
|
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<string, number>();
|
|
/**
|
|
* 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다.
|
|
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
|
|
*/
|
|
let slowEngineGate: Promise<void> | null = null;
|
|
/** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */
|
|
let slowMailboxGate: Promise<void> | null = null;
|
|
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
|
|
const drafts = new Map<
|
|
string,
|
|
{ user: string; content: DraftContent; updatedAt: string }
|
|
>();
|
|
let draftSequence = 0;
|
|
/** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */
|
|
const deletedDrafts: string[] = [];
|
|
/** 대역 엔진이 받은 발송 */
|
|
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
|
|
/** 대역 엔진이 받는 사람을 모두 받지 않은 발송 — 보내지 않은 것이라 sentMessages 에 넣지 않는다 */
|
|
const refusedSends: { user: string; message: OutgoingMessage }[] = [];
|
|
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
|
|
const correspondents = new Map<string, Correspondence[]>();
|
|
|
|
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
|
|
const listQueries: { user: string; query: MessageListQuery }[] = [];
|
|
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
|
const bulkUpdates: {
|
|
user: string;
|
|
ids: readonly string[];
|
|
update: MessageUpdate;
|
|
}[] = [];
|
|
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
|
|
|
|
/** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */
|
|
const passwordChanges: { email: string; newPassword: string }[] = [];
|
|
const mailboxCalls: { user: string; action: string; args: unknown[] }[] = [];
|
|
const vacationSaves: { user: string; vacation: MailVacation }[] = [];
|
|
/** 6단계 — 대역 엔진의 엔진 규칙(직원마다 하나): 필터 규칙과 부재중 자동 응답을 함께 둔다 (계약 2절 "메일 필터"의 저장 자리) */
|
|
const mailScripts = new Map<
|
|
string,
|
|
{ rules: readonly MailFilterRule[]; vacation: MailVacation }
|
|
>();
|
|
const filterSaves: { user: string; update: MailFilterUpdate }[] = [];
|
|
const singleUpdates: {
|
|
user: string;
|
|
id: string;
|
|
update: MessageUpdate;
|
|
}[] = [];
|
|
|
|
/** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */
|
|
const liveWatches: {
|
|
user: string;
|
|
listener: MailWatchListener;
|
|
closed: boolean;
|
|
}[] = [];
|
|
|
|
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
|
const sign = (payload: object, expiresIn: number) =>
|
|
jwt.sign(payload, {
|
|
secret: authSettings.jwtSecret,
|
|
algorithm: 'HS256',
|
|
expiresIn,
|
|
});
|
|
|
|
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
|
const cookieOf = (email: string) =>
|
|
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
|
|
|
/** 로그인해서 두 쿠키 값을 받는다 (서버에 세션이 생긴다) */
|
|
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: PROFILE_NAMES.get(email) ?? 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;
|
|
},
|
|
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다.
|
|
// 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx)
|
|
findSentMessage: () => Promise.resolve(null),
|
|
getRejectedRecipients: () => Promise.resolve([]),
|
|
sendMessage: (
|
|
user: string,
|
|
message: OutgoingMessage,
|
|
): Promise<SentMessage> => {
|
|
const recipients = [...message.to, ...message.cc, ...message.bcc];
|
|
const rejectedRecipients = recipients.flatMap(({ email }) => {
|
|
const reason = ENGINE_REJECTIONS.get(email);
|
|
return reason ? [{ email, reason }] : [];
|
|
});
|
|
if (rejectedRecipients.length === recipients.length) {
|
|
refusedSends.push({ user, message });
|
|
const code = rejectedRecipients.some(
|
|
({ reason }) => reason !== 'temporary',
|
|
)
|
|
? 'MAIL_010'
|
|
: 'MAIL_011';
|
|
return Promise.reject(
|
|
new BusinessException(code, {
|
|
context: { reason: 'e2e-all-recipients-rejected' },
|
|
}),
|
|
);
|
|
}
|
|
sentMessages.push({ user, message });
|
|
return Promise.resolve({
|
|
id: `sent${sentMessages.length}`,
|
|
rejectedRecipients,
|
|
});
|
|
},
|
|
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
|
|
saveDraft: (
|
|
user: string,
|
|
content: DraftContent,
|
|
replaceId?: string,
|
|
): Promise<SavedDraft> => {
|
|
if (replaceId === DRAFT_CHANGED_ELSEWHERE) {
|
|
return Promise.reject(
|
|
new BusinessException('MAIL_008', {
|
|
context: { reason: 'e2e-draft-replaced-elsewhere' },
|
|
}),
|
|
);
|
|
}
|
|
if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) {
|
|
return Promise.reject(
|
|
new BusinessException('RES_001', {
|
|
context: { reason: 'e2e-not-a-draft' },
|
|
}),
|
|
);
|
|
}
|
|
draftSequence += 1;
|
|
const id = `draft${draftSequence}`;
|
|
const updatedAt = new Date().toISOString();
|
|
drafts.set(id, { user, content, updatedAt });
|
|
if (replaceId !== undefined) drafts.delete(replaceId);
|
|
return Promise.resolve({ id, attachments: [], updatedAt });
|
|
},
|
|
getDraft: (user: string, id: string): Promise<DraftSource> => {
|
|
const draft = drafts.get(id);
|
|
if (!draft || draft.user !== user) {
|
|
return Promise.reject(
|
|
new BusinessException('RES_001', {
|
|
context: { reason: 'e2e-not-a-draft' },
|
|
}),
|
|
);
|
|
}
|
|
const { content, updatedAt } = draft;
|
|
return Promise.resolve({
|
|
id,
|
|
to: content.to,
|
|
cc: content.cc,
|
|
bcc: content.bcc,
|
|
subject: content.subject,
|
|
textBody: content.body,
|
|
rawHtmlBody: content.htmlBody ?? null,
|
|
attachments: [],
|
|
inReplyTo: null,
|
|
updatedAt,
|
|
});
|
|
},
|
|
deleteDraft: (user: string, id: string) => {
|
|
deletedDrafts.push(id);
|
|
if (drafts.get(id)?.user === user) drafts.delete(id);
|
|
return Promise.resolve();
|
|
},
|
|
listCorrespondents: (user: string) =>
|
|
Promise.resolve(correspondents.get(user) ?? []),
|
|
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
|
|
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
|
|
// 5단계 — 엔진 연결(다시 잇기 · 새 메일 계산)은 jmap-mail-watch.spec.ts · jmap-mail.provider.spec.ts 가 본다.
|
|
// 여기서는 처음 상태만 주고, 소식은 테스트가 listener 로 흘려 넣는다
|
|
watchMail: (
|
|
user: string,
|
|
listener: MailWatchListener,
|
|
): Promise<MailWatch> => {
|
|
if (user === EVENTS_ENGINE_DOWN) {
|
|
return Promise.reject(
|
|
new BusinessException('MAIL_001', {
|
|
context: { reason: 'e2e-engine-down' },
|
|
}),
|
|
);
|
|
}
|
|
const watch = { user, listener, closed: false };
|
|
liveWatches.push(watch);
|
|
return Promise.resolve({
|
|
initial: { state: MAIL_STATE, inboxUnread: 2 },
|
|
close: () => {
|
|
watch.closed = true;
|
|
},
|
|
});
|
|
},
|
|
listMessages: (user: string, query: MessageListQuery) => {
|
|
listQueries.push({ user, query });
|
|
return Promise.resolve({
|
|
items: [],
|
|
hasNext: false,
|
|
nextCursor: null,
|
|
total: 0,
|
|
});
|
|
},
|
|
updateMessages: (
|
|
user: string,
|
|
ids: readonly string[],
|
|
update: MessageUpdate,
|
|
): Promise<MessagesPatchResult> => {
|
|
bulkUpdates.push({ user, ids, update });
|
|
if (update.mailboxId === MISSING_MAILBOX) {
|
|
return Promise.reject(
|
|
new BusinessException('RES_001', {
|
|
context: { reason: 'e2e-mailbox-not-found' },
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve({
|
|
updatedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
|
failedIds: ids.filter((id) => id === GONE_MESSAGE),
|
|
});
|
|
},
|
|
deleteFromTrash: (
|
|
user: string,
|
|
ids: readonly string[],
|
|
): Promise<MessageDeletionResult> => {
|
|
trashDeletions.push({ user, ids });
|
|
return Promise.resolve({
|
|
deletedIds: ids.filter((id) => id !== GONE_MESSAGE),
|
|
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
|
|
});
|
|
},
|
|
// 4단계 — 엔진 쪽 판단(본인 자격의 비밀번호 바꾸기 · 조건부로 옮기기 · 비우기)은 jmap-*.spec.ts 가 본다. 여기서는 받은 값만 흉내 낸다
|
|
changePassword: (
|
|
email: string,
|
|
currentPassword: string,
|
|
newPassword: string,
|
|
): Promise<PasswordChangeOutcome> => {
|
|
passwordChanges.push({ email, newPassword });
|
|
if (currentPassword !== CORRECT_PASSWORD) {
|
|
return Promise.resolve('current-password-rejected');
|
|
}
|
|
const outcomes = new Map<string, PasswordChangeOutcome>([
|
|
[ENGINE_REJECTED_PASSWORD, 'new-password-rejected'],
|
|
[ENGINE_REFUSED_PASSWORD, 'current-password-refused'],
|
|
[ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'],
|
|
]);
|
|
return Promise.resolve(outcomes.get(newPassword) ?? 'changed');
|
|
},
|
|
createMailbox: (
|
|
user: string,
|
|
input: MailboxCreate,
|
|
): Promise<Mailbox> => {
|
|
mailboxCalls.push({ user, action: 'create', args: [input] });
|
|
if (input.name === TAKEN_MAILBOX_NAME) {
|
|
return Promise.reject(
|
|
new BusinessException('MAIL_009', {
|
|
context: { reason: 'e2e-mailbox-name-taken' },
|
|
}),
|
|
);
|
|
}
|
|
if (input.parentId === MISSING_MAILBOX) {
|
|
return Promise.reject(
|
|
new BusinessException('RES_001', {
|
|
context: { reason: 'e2e-parent-not-found' },
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve({
|
|
id: 'newbox',
|
|
name: input.name,
|
|
role: null,
|
|
parentId: input.parentId,
|
|
sortOrder: 0,
|
|
totalCount: 0,
|
|
unreadCount: 0,
|
|
});
|
|
},
|
|
renameMailbox: (
|
|
user: string,
|
|
id: string,
|
|
name: string,
|
|
): Promise<Mailbox> => {
|
|
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
|
|
return Promise.resolve({
|
|
id,
|
|
name,
|
|
role: null,
|
|
parentId: null,
|
|
sortOrder: 0,
|
|
totalCount: 3,
|
|
unreadCount: 1,
|
|
});
|
|
},
|
|
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
|
|
mailboxCalls.push({ user, action: 'delete', args: [id] });
|
|
return Promise.resolve({
|
|
deleted: true,
|
|
processed: 4,
|
|
movedToTrash: 3,
|
|
remaining: 0,
|
|
});
|
|
},
|
|
markMailboxRead: (
|
|
user: string,
|
|
id: string,
|
|
): Promise<MailboxBulkResult> => {
|
|
mailboxCalls.push({ user, action: 'mark-read', args: [id] });
|
|
return Promise.resolve({ processed: 5, remaining: 0 });
|
|
},
|
|
emptyMailbox: async (
|
|
user: string,
|
|
id: string,
|
|
): Promise<MailboxBulkResult> => {
|
|
mailboxCalls.push({ user, action: 'empty', args: [id] });
|
|
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
|
|
return { processed: 2, remaining: 0 };
|
|
},
|
|
getVacation: (user: string): Promise<MailVacation> =>
|
|
Promise.resolve(
|
|
mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION,
|
|
),
|
|
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
|
|
setVacation: (
|
|
user: string,
|
|
vacation: MailVacation,
|
|
): Promise<MailVacation> => {
|
|
vacationSaves.push({ user, vacation });
|
|
mailScripts.set(user, {
|
|
rules: mailScripts.get(user)?.rules ?? [],
|
|
vacation,
|
|
});
|
|
return Promise.resolve(vacation);
|
|
},
|
|
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
|
|
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
|
|
getMailFilters: (user: string): Promise<MailFilterSet> => {
|
|
const rules = mailScripts.get(user)?.rules ?? [];
|
|
return Promise.resolve({
|
|
rules,
|
|
version: fakeFilterVersion(rules),
|
|
isActive: true,
|
|
});
|
|
},
|
|
setMailFilters: (
|
|
user: string,
|
|
update: MailFilterUpdate,
|
|
): Promise<MailFilterSet> => {
|
|
filterSaves.push({ user, update });
|
|
const current = mailScripts.get(user);
|
|
const version = fakeFilterVersion(current?.rules ?? []);
|
|
if (
|
|
version !== update.version &&
|
|
version !== fakeFilterVersion(update.rules)
|
|
) {
|
|
return Promise.reject(
|
|
new BusinessException('MAIL_012', {
|
|
context: { reason: 'e2e-filter-rules-changed' },
|
|
}),
|
|
);
|
|
}
|
|
if (
|
|
update.rules.some((rule) =>
|
|
rule.conditions.some(
|
|
({ value }) => value === ENGINE_TOO_LARGE_FILTER_VALUE,
|
|
),
|
|
)
|
|
) {
|
|
return Promise.reject(
|
|
new BusinessException('MAIL_013', {
|
|
context: { reason: 'e2e-filter-script-too-large' },
|
|
}),
|
|
);
|
|
}
|
|
mailScripts.set(user, {
|
|
rules: update.rules,
|
|
vacation: current?.vacation ?? NEVER_CONFIGURED_VACATION,
|
|
});
|
|
return Promise.resolve({
|
|
rules: update.rules,
|
|
version: fakeFilterVersion(update.rules),
|
|
isActive: true,
|
|
});
|
|
},
|
|
openMessageSource: (
|
|
_user: string,
|
|
id: string,
|
|
): Promise<MessageSource> => {
|
|
if (id === GONE_MESSAGE) {
|
|
return Promise.reject(
|
|
new BusinessException('RES_001', {
|
|
context: { reason: 'e2e-message-not-found' },
|
|
}),
|
|
);
|
|
}
|
|
return Promise.resolve({
|
|
stream: Readable.from([Buffer.from(RAW_MESSAGE, 'utf8')]),
|
|
subject: RAW_MESSAGE_SUBJECT,
|
|
size: Buffer.byteLength(RAW_MESSAGE, 'utf8'),
|
|
});
|
|
},
|
|
updateMessage: (
|
|
user: string,
|
|
id: string,
|
|
update: MessageUpdate,
|
|
): Promise<MessageSummary> => {
|
|
singleUpdates.push({ user, id, update });
|
|
return Promise.resolve({
|
|
id,
|
|
threadId: id,
|
|
mailboxIds: [update.mailboxId ?? 'a'],
|
|
from: null,
|
|
to: [],
|
|
subject: '',
|
|
preview: '',
|
|
receivedAt: '2026-10-28T01:00:00.000Z',
|
|
isRead: false,
|
|
isFlagged: false,
|
|
hasAttachment: false,
|
|
size: 1,
|
|
});
|
|
},
|
|
})
|
|
.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],
|
|
);
|
|
// 서명 그림은 파일도 지운다 (개발 업로드 볼륨에 남기지 않는다)
|
|
const images: { file_name: string }[] = await dataSource.query(
|
|
'SELECT file_name FROM mail_signature_images WHERE user_email LIKE $1',
|
|
[TEST_DOMAIN_PATTERN],
|
|
);
|
|
for (const { file_name: fileName } of images) {
|
|
const stored = join(SIGNATURE_IMAGE_DIR, basename(fileName));
|
|
if (existsSync(stored)) unlinkSync(stored);
|
|
}
|
|
for (const statement of [
|
|
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
|
|
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
|
|
'DELETE FROM mail_directory WHERE email LIKE $1',
|
|
]) {
|
|
await dataSource.query(statement, [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 · 여러 통 PATCH)·영구 삭제에서는 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'],
|
|
['patch', '/api/messages'],
|
|
['post', '/api/message-deletions'],
|
|
] 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');
|
|
}
|
|
});
|
|
|
|
/**
|
|
* JSON 본문의 모양 한도(app.setup.ts 의 JSON_SHAPE_LIMITS — 배열 1,000 · 값 10,000 · 깊이 32)와 UTF-8 만.
|
|
* 본문 파서 단계라 인증 · 요청 수 한도보다 앞이다 — 로그인 전의 요청도 JSON.parse · DTO 변환 · 검사 전에 끊는다.
|
|
* CPU 시간은 app.setup.spec.ts · json-shape.spec.ts 가 잰다 — 여기서는 실제 HTTP 응답(상태 · 코드 · traceId)을 본다
|
|
*/
|
|
describe('JSON 본문의 모양 한도 · UTF-8 만 (본문 파서 단계)', () => {
|
|
const expectRejectedBody = (res: Response) => {
|
|
expect(res.status).toBe(400);
|
|
expect(body(res).success).toBe(false);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
|
};
|
|
const items = (count: number, item: (index: number) => string) =>
|
|
Array.from({ length: count }, (_, index) => item(index)).join(',');
|
|
|
|
it('필터 저장(1MB 경로)에 규칙 8만 개 — 로그인한 직원도 DTO 검사 전에 400 VAL_001, 엔진에 묻지 않는다', async () => {
|
|
const before = filterSaves.length;
|
|
const res = await request(server)
|
|
.put('/api/mail-filters')
|
|
.set('Cookie', cookieOf('shape.me@e2e.arasystem.test'))
|
|
.set('Content-Type', 'application/json')
|
|
.send(
|
|
`{"version":"v","rules":[${items(80_000, (index) => `{"id":${index}}`)}]}`,
|
|
);
|
|
|
|
expectRejectedBody(res);
|
|
expect(filterSaves).toHaveLength(before);
|
|
});
|
|
|
|
it('발송(4MB 경로)의 to 에 {} 139만 개 — 로그인 전이어도 401 이 아니라 400 VAL_001 (본문을 읽기 전에 끊는다)', async () => {
|
|
const text = `{"subject":"x","body":"x","to":[${items(1_390_000, () => '{}')}]}`;
|
|
expect(text.length).toBeGreaterThan(4_000_000);
|
|
|
|
expectRejectedBody(
|
|
await request(server)
|
|
.post('/api/messages')
|
|
.set('Content-Type', 'application/json')
|
|
.send(text),
|
|
);
|
|
});
|
|
|
|
it('10만 단 중첩한 본문은 400 VAL_001 — 전에는 DTO 변환의 재귀가 넘쳐 500 SYS_001 이었다', async () => {
|
|
expectRejectedBody(
|
|
await request(server)
|
|
.post('/api/auth/login')
|
|
.set('Content-Type', 'application/json')
|
|
.send(
|
|
`{"email":"a@b.c","password":"x","z":${'{"a":'.repeat(100_000)}1${'}'.repeat(100_000)}}`,
|
|
),
|
|
);
|
|
});
|
|
|
|
it('UTF-8 이 아닌 JSON 본문(charset=utf-16le · utf-7)은 작아도 400 VAL_001 — utf-_8 처럼 UTF-8 로 읽히는 이름은 받는다', async () => {
|
|
const payload = JSON.stringify({ ids: ['eaaaaab'], isRead: true });
|
|
for (const [charset, bytes] of [
|
|
['utf-16le', Buffer.from(payload, 'utf16le')],
|
|
['utf-7', Buffer.from(payload, 'latin1')],
|
|
] as const) {
|
|
expectRejectedBody(
|
|
await request(server)
|
|
.patch('/api/messages')
|
|
.set('Content-Type', `application/json; charset=${charset}`)
|
|
.send(bytes),
|
|
);
|
|
}
|
|
|
|
// 본문 파서를 지나 인증 검사에 닿는다 — 로그인하지 않아 401
|
|
const utf8 = await request(server)
|
|
.patch('/api/messages')
|
|
.set('Content-Type', 'application/json; charset=utf-_8')
|
|
.send(payload)
|
|
.expect(401);
|
|
expect(body(utf8).error?.code).toBe('AUTH_001');
|
|
});
|
|
});
|
|
|
|
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'],
|
|
['get', '/api/drafts/eaaaaab'],
|
|
['post', '/api/drafts'],
|
|
['put', '/api/drafts/eaaaaab'],
|
|
['get', '/api/mail-signature'],
|
|
['put', '/api/mail-signature'],
|
|
['post', '/api/mail-signature/images'],
|
|
[
|
|
'get',
|
|
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
|
],
|
|
['get', '/api/recipient-suggestions'],
|
|
['get', '/api/mail-state'],
|
|
['get', '/api/mail-events'],
|
|
['get', '/api/messages?q=%ED%9A%8C%EC%9D%98'],
|
|
['patch', '/api/messages'],
|
|
['post', '/api/message-deletions'],
|
|
['put', '/api/auth/password'],
|
|
['post', '/api/mailboxes'],
|
|
['patch', '/api/mailboxes/u1'],
|
|
['delete', '/api/mailboxes/u1'],
|
|
['patch', '/api/mailboxes/u1/messages'],
|
|
['delete', '/api/mailboxes/u1/messages'],
|
|
['get', '/api/mail-vacation'],
|
|
['put', '/api/mail-vacation'],
|
|
['get', '/api/mail-filters'],
|
|
['put', '/api/mail-filters'],
|
|
['get', '/api/messages/eaaaaab/source'],
|
|
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
|
['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,
|
|
);
|
|
// 한도는 엔진에 묻기 전에 본다 — 대역 엔진이 받은 발송이 늘지 않아야 한다
|
|
const sentBefore = sentMessages.length;
|
|
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');
|
|
expect(sentMessages).toHaveLength(sentBefore);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 2단계 — 임시 저장 · 서명 · 받는 사람 추천 (계약 2절).
|
|
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
|
|
*/
|
|
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
|
|
const UUID =
|
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
|
|
|
describe('임시 저장 (/api/drafts)', () => {
|
|
const save = (method: 'post' | 'put', path: string, payload: object) =>
|
|
request(server)
|
|
[method](path)
|
|
.set('Cookie', cookieOf(EMPLOYEE.email))
|
|
.send({ to: [], subject: '쓰는 중', body: '본문', ...payload });
|
|
|
|
it('받는 사람 없이도 처음 저장(POST)은 201 — 서식 본문은 보내는 정화를 거쳐 엔진에 넘어간다', async () => {
|
|
const res = await save('post', '/api/drafts', {
|
|
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
|
|
}).expect(201);
|
|
|
|
const saved = dataOf<SavedDraft>(res);
|
|
expect(saved).toEqual({
|
|
id: expect.stringMatching(/^draft\d+$/) as string,
|
|
attachments: [],
|
|
updatedAt: expect.any(String) as string,
|
|
});
|
|
expect(drafts.get(saved.id)?.content).toMatchObject({
|
|
to: [],
|
|
htmlBody: '<p>쓰는 중</p>',
|
|
});
|
|
});
|
|
|
|
it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => {
|
|
const first = dataOf<SavedDraft>(
|
|
await save('post', '/api/drafts', {}).expect(201),
|
|
);
|
|
const second = dataOf<SavedDraft>(
|
|
await save('put', `/api/drafts/${first.id}`, {
|
|
to: [DOYUN],
|
|
subject: '고친 제목',
|
|
bodyHtml:
|
|
'<p><strong>굵게</strong><img src="https://x.test/p.gif"></p>',
|
|
}).expect(200),
|
|
);
|
|
expect(second.id).not.toBe(first.id);
|
|
expect(drafts.has(first.id)).toBe(false);
|
|
|
|
const res = await request(server)
|
|
.get(`/api/drafts/${second.id}`)
|
|
.set('Cookie', cookieOf(EMPLOYEE.email))
|
|
.expect(200);
|
|
expect(dataOf<unknown>(res)).toEqual({
|
|
id: second.id,
|
|
to: [DOYUN],
|
|
cc: [],
|
|
bcc: [],
|
|
subject: '고친 제목',
|
|
body: '본문',
|
|
bodyHtml: '<p><strong>굵게</strong></p>',
|
|
attachments: [],
|
|
inReplyTo: null,
|
|
updatedAt: second.updatedAt,
|
|
});
|
|
});
|
|
|
|
it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => {
|
|
const changed = await save(
|
|
'put',
|
|
`/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`,
|
|
{},
|
|
).expect(409);
|
|
expect(body(changed).error?.code).toBe('MAIL_008');
|
|
expect(body(changed).error?.traceId).toBe(
|
|
changed.headers['x-request-id'],
|
|
);
|
|
|
|
for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) {
|
|
const res = await save('put', path, {}).expect(404);
|
|
expect(body(res).error?.code).toBe('RES_001');
|
|
}
|
|
});
|
|
|
|
it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => {
|
|
const mine = dataOf<SavedDraft>(
|
|
await save('post', '/api/drafts', {}).expect(201),
|
|
);
|
|
|
|
const res = await request(server)
|
|
.get(`/api/drafts/${mine.id}`)
|
|
.set('Cookie', cookieOf('other.person@e2e.arasystem.test'))
|
|
.expect(404);
|
|
expect(body(res).error?.code).toBe('RES_001');
|
|
});
|
|
|
|
it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => {
|
|
const recipients = (count: number) =>
|
|
Array.from({ length: count }, (_, index) => ({
|
|
name: null,
|
|
email: `r${index}@e2e.arasystem.test`,
|
|
}));
|
|
const cases: [object, number, string][] = [
|
|
[{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'],
|
|
[{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'],
|
|
[{ draftId: 'eaaaaab' }, 400, 'VAL_001'],
|
|
];
|
|
|
|
const before = drafts.size;
|
|
for (const [payload, status, code] of cases) {
|
|
const res = await save('post', '/api/drafts', payload).expect(status);
|
|
expect(body(res).error?.code).toBe(code);
|
|
}
|
|
expect(drafts.size).toBe(before);
|
|
});
|
|
|
|
it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
|
|
for (const [method, path] of [
|
|
['post', '/api/drafts'],
|
|
['put', '/api/drafts/eaaaaab'],
|
|
] as const) {
|
|
const parsed = await request(server)
|
|
[method](path)
|
|
.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)
|
|
[method](path)
|
|
.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');
|
|
}
|
|
});
|
|
|
|
it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => {
|
|
const draft = dataOf<SavedDraft>(
|
|
await save('post', '/api/drafts', { to: [DOYUN] }).expect(201),
|
|
);
|
|
const send = (draftId: string) =>
|
|
request(server)
|
|
.post('/api/messages')
|
|
.set('Cookie', cookieOf(EMPLOYEE.email))
|
|
.send({ to: [DOYUN], subject: '제목', body: '본문', draftId });
|
|
|
|
const sentBefore = sentMessages.length;
|
|
const malformed = await send('../draft1').expect(400);
|
|
expect(body(malformed).error?.code).toBe('VAL_001');
|
|
expect(sentMessages).toHaveLength(sentBefore);
|
|
|
|
await send(draft.id).expect(201);
|
|
expect(sentMessages).toHaveLength(sentBefore + 1);
|
|
// 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다)
|
|
expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId');
|
|
expect(deletedDrafts).toContain(draft.id);
|
|
expect(drafts.has(draft.id)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('서명 (/api/mail-signature)', () => {
|
|
const imageUrl = (id: string) => `/api/mail-signature/images/${id}`;
|
|
const uploadAs = (
|
|
email: string,
|
|
data: Buffer,
|
|
filename = 'logo.png',
|
|
contentType = 'image/png',
|
|
) =>
|
|
request(server)
|
|
.post('/api/mail-signature/images')
|
|
.set('Cookie', cookieOf(email))
|
|
.attach('file', data, { filename, contentType });
|
|
const uploaded = async (email: string, data = pngBytes(480, 120)) =>
|
|
dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id;
|
|
const saveSignature = (email: string, payload: object) =>
|
|
request(server)
|
|
.put('/api/mail-signature')
|
|
.set('Cookie', cookieOf(email))
|
|
.send({ isForNew: true, isForReply: true, ...payload });
|
|
|
|
it('저장한 적 없으면 기본값', async () => {
|
|
const res = await request(server)
|
|
.get('/api/mail-signature')
|
|
.set('Cookie', cookieOf('sign.default@e2e.arasystem.test'))
|
|
.expect(200);
|
|
|
|
expect(dataOf<unknown>(res)).toEqual({
|
|
bodyHtml: null,
|
|
body: null,
|
|
isForNew: true,
|
|
isForReply: true,
|
|
images: [],
|
|
updatedAt: null,
|
|
});
|
|
});
|
|
|
|
it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => {
|
|
const owner = 'sign.owner@e2e.arasystem.test';
|
|
const data = pngBytes(480, 120, 2_048);
|
|
|
|
const image = dataOf<Record<string, unknown>>(
|
|
await uploadAs(owner, data, '회사 로고.png').expect(201),
|
|
);
|
|
expect(image).toEqual({
|
|
id: expect.stringMatching(UUID) as string,
|
|
type: 'image/png',
|
|
size: 2_048,
|
|
width: 480,
|
|
height: 120,
|
|
});
|
|
const id = String(image.id);
|
|
|
|
const res = await request(server)
|
|
.get(imageUrl(id))
|
|
.set('Cookie', cookieOf(owner))
|
|
.expect(200);
|
|
expect(res.headers['content-type']).toBe('image/png');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res.headers['cache-control']).toBe('private, max-age=86400');
|
|
expect(res.headers['content-disposition']).toBe(
|
|
'inline; filename="signature.png"',
|
|
);
|
|
expect(res.body).toEqual(data);
|
|
|
|
for (const [cookie, path] of [
|
|
[cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)],
|
|
[cookieOf(owner), imageUrl('not-a-uuid')],
|
|
[cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')],
|
|
]) {
|
|
const denied = await request(server)
|
|
.get(path)
|
|
.set('Cookie', cookie)
|
|
.expect(404);
|
|
expect(body(denied).error?.code).toBe('RES_001');
|
|
}
|
|
});
|
|
|
|
it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => {
|
|
const user = 'sign.reject@e2e.arasystem.test';
|
|
for (const [data, filename, contentType] of [
|
|
// 이름·MIME 은 PNG 인데 내용은 HTML
|
|
[
|
|
Buffer.from('<html><script>alert(1)</script></html>'),
|
|
'logo.png',
|
|
'image/png',
|
|
],
|
|
// 이름과 MIME 이 다르다
|
|
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
|
[pngBytes(10, 10), 'logo.webp', 'image/webp'],
|
|
[Buffer.from('<svg onload="x()"/>'), 'logo.svg', 'image/svg+xml'],
|
|
] as const) {
|
|
const res = await uploadAs(user, data, filename, contentType).expect(
|
|
400,
|
|
);
|
|
expect(body(res).error?.code).toBe('FILE_001');
|
|
}
|
|
|
|
const tooLarge = await uploadAs(
|
|
user,
|
|
pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1),
|
|
).expect(413);
|
|
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
|
|
|
const missing = await request(server)
|
|
.post('/api/mail-signature/images')
|
|
.set('Cookie', cookieOf(user))
|
|
.expect(400);
|
|
expect(body(missing).error?.code).toBe('VAL_001');
|
|
|
|
// 하나도 저장되지 않았다
|
|
const rows: { id: string }[] = await dataSource.query(
|
|
'SELECT id FROM mail_signature_images WHERE user_email = $1',
|
|
[user],
|
|
);
|
|
expect(rows).toEqual([]);
|
|
});
|
|
|
|
it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => {
|
|
const user = 'sign.many@e2e.arasystem.test';
|
|
const ids: string[] = [];
|
|
for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) {
|
|
ids.push(await uploaded(user));
|
|
}
|
|
|
|
const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400);
|
|
expect(body(overflow).error?.code).toBe('VAL_001');
|
|
|
|
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
|
const tooMany = await saveSignature(user, {
|
|
bodyHtml: ids
|
|
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1)
|
|
.map(img)
|
|
.join(''),
|
|
}).expect(400);
|
|
expect(body(tooMany).error?.code).toBe('VAL_001');
|
|
|
|
const fits = await saveSignature(user, {
|
|
bodyHtml: ids
|
|
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)
|
|
.map(img)
|
|
.join(''),
|
|
}).expect(200);
|
|
expect(
|
|
dataOf<{ images: { id: string }[] }>(fits).images.map(
|
|
(image) => image.id,
|
|
),
|
|
).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE));
|
|
});
|
|
|
|
it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => {
|
|
const user = 'sign.save@e2e.arasystem.test';
|
|
const [a, b] = [await uploaded(user), await uploaded(user)];
|
|
|
|
const first = await saveSignature(user, {
|
|
bodyHtml: `<p onclick="x()">김아라</p><img src="${imageUrl(a)}" width="144" alt=""><img src="${imageUrl(b)}" width="96"><img src="${imageUrl('11111111-1111-4111-8111-111111111111')}">`,
|
|
body: '김아라 · 아라시스템',
|
|
isForReply: false,
|
|
}).expect(200);
|
|
expect(dataOf<Record<string, unknown>>(first)).toMatchObject({
|
|
bodyHtml: `<p>김아라</p><img src="${imageUrl(a)}" width="144" alt="" /><img src="${imageUrl(b)}" width="96" />`,
|
|
body: '김아라 · 아라시스템',
|
|
isForNew: true,
|
|
isForReply: false,
|
|
images: [
|
|
expect.objectContaining({ id: a }) as unknown,
|
|
expect.objectContaining({ id: b }) as unknown,
|
|
],
|
|
updatedAt: expect.any(String) as string,
|
|
});
|
|
|
|
// b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다
|
|
const c = await uploaded(user);
|
|
const second = await saveSignature(user, {
|
|
bodyHtml: `<img src="${imageUrl(a)}" width="144">`,
|
|
body: null,
|
|
}).expect(200);
|
|
expect(
|
|
dataOf<{ images: { id: string }[] }>(second).images.map(
|
|
(image) => image.id,
|
|
),
|
|
).toEqual([a]);
|
|
|
|
const statusOf = async (id: string) =>
|
|
(
|
|
await request(server)
|
|
.get(imageUrl(id))
|
|
.set('Cookie', cookieOf(user))
|
|
).status;
|
|
expect(await statusOf(a)).toBe(200);
|
|
expect(await statusOf(b)).toBe(404);
|
|
expect(await statusOf(c)).toBe(200);
|
|
// 지운 그림은 파일도 없다
|
|
expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false);
|
|
|
|
const current = await request(server)
|
|
.get('/api/mail-signature')
|
|
.set('Cookie', cookieOf(user))
|
|
.expect(200);
|
|
expect(dataOf<unknown>(current)).toEqual(dataOf<unknown>(second));
|
|
});
|
|
|
|
it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => {
|
|
const user = 'sign.limits@e2e.arasystem.test';
|
|
for (const payload of [
|
|
{ body: '가'.repeat(2_001) },
|
|
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
|
]) {
|
|
const res = await saveSignature(user, payload).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
});
|
|
|
|
it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => {
|
|
const user = 'sign.body@e2e.arasystem.test';
|
|
const ids: string[] = [];
|
|
for (
|
|
let index = 0;
|
|
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
|
index += 1
|
|
) {
|
|
ids.push(await uploaded(user, pngBytes(40, 10)));
|
|
}
|
|
const bodyHtml = `<p>본문</p>${ids.map((id) => `<img src="${imageUrl(id)}" width="96">`).join('')}`;
|
|
const sentBefore = sentMessages.length;
|
|
const draftsBefore = drafts.size;
|
|
|
|
const send = await request(server)
|
|
.post('/api/messages')
|
|
.set('Cookie', cookieOf(user))
|
|
.send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml })
|
|
.expect(400);
|
|
expect(body(send).error?.code).toBe('VAL_001');
|
|
const draft = await request(server)
|
|
.post('/api/drafts')
|
|
.set('Cookie', cookieOf(user))
|
|
.send({ to: [], subject: '그림 넷', body: '본문', bodyHtml })
|
|
.expect(400);
|
|
expect(body(draft).error?.code).toBe('VAL_001');
|
|
|
|
expect(sentMessages).toHaveLength(sentBefore);
|
|
expect(drafts.size).toBe(draftsBefore);
|
|
});
|
|
|
|
it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => {
|
|
const user = 'sign.race@e2e.arasystem.test';
|
|
const [a, b] = [await uploaded(user), await uploaded(user)];
|
|
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
|
await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect(
|
|
200,
|
|
);
|
|
|
|
// 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다
|
|
const [first, second] = await Promise.all([
|
|
saveSignature(user, { bodyHtml: `<p>탭 1</p>${img(a)}` }),
|
|
saveSignature(user, { bodyHtml: `<p>탭 2</p>${img(b)}` }),
|
|
]);
|
|
expect([first.status, second.status]).toEqual([200, 200]);
|
|
|
|
const final = dataOf<{
|
|
bodyHtml: string | null;
|
|
images: { id: string }[];
|
|
}>(
|
|
await request(server)
|
|
.get('/api/mail-signature')
|
|
.set('Cookie', cookieOf(user))
|
|
.expect(200),
|
|
);
|
|
const referenced = [
|
|
...(final.bodyHtml ?? '').matchAll(
|
|
/\/api\/mail-signature\/images\/([0-9a-f-]{36})/g,
|
|
),
|
|
].map((match) => match[1]);
|
|
expect(final.images.map((image) => image.id)).toEqual(referenced);
|
|
// 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다
|
|
for (const id of [a, b]) {
|
|
const res = await request(server)
|
|
.get(imageUrl(id))
|
|
.set('Cookie', cookieOf(user));
|
|
expect(res.status).toBe(referenced.includes(id) ? 200 : 404);
|
|
}
|
|
});
|
|
|
|
it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => {
|
|
const user = 'sign.cleanup@e2e.arasystem.test';
|
|
const [attached, loose] = [await uploaded(user), await uploaded(user)];
|
|
await saveSignature(user, {
|
|
bodyHtml: `<img src="${imageUrl(attached)}" width="96">`,
|
|
}).expect(200);
|
|
|
|
// 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다
|
|
const repository = app.get(MailSignatureRepository, { strict: false });
|
|
const deleted = await repository.deleteUnattachedImages([
|
|
attached,
|
|
loose,
|
|
]);
|
|
// 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게)
|
|
const left: { id: string }[] = await dataSource.query(
|
|
'SELECT id FROM mail_signature_images WHERE id = ANY($1)',
|
|
[[attached, loose]],
|
|
);
|
|
for (const id of [attached, loose]) {
|
|
const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`);
|
|
if (!left.some((row) => row.id === id) && existsSync(stored)) {
|
|
unlinkSync(stored);
|
|
}
|
|
}
|
|
expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]);
|
|
|
|
const statusOf = async (id: string) =>
|
|
(
|
|
await request(server)
|
|
.get(imageUrl(id))
|
|
.set('Cookie', cookieOf(user))
|
|
).status;
|
|
expect(await statusOf(attached)).toBe(200);
|
|
expect(await statusOf(loose)).toBe(404);
|
|
});
|
|
|
|
it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => {
|
|
const user = 'sign.send@e2e.arasystem.test';
|
|
const data = pngBytes(200, 50, 1_000);
|
|
const id = await uploaded(user, data);
|
|
const html = `<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고">`;
|
|
const cid = `sig-${id}@e2e.arasystem.test`;
|
|
|
|
await request(server)
|
|
.post('/api/messages')
|
|
.set('Cookie', cookieOf(user))
|
|
.send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html })
|
|
.expect(201);
|
|
const sent = sentMessages[sentMessages.length - 1];
|
|
expect(sent.user).toBe(user);
|
|
expect(sent.message.htmlBody).toBe(
|
|
`<p>본문</p><img src="cid:${cid}" width="144" alt="로고" />`,
|
|
);
|
|
expect(sent.message.inlineImages).toEqual([
|
|
{ cid, type: 'image/png', name: 'signature-1.png', data },
|
|
]);
|
|
|
|
const saved = dataOf<SavedDraft>(
|
|
await request(server)
|
|
.post('/api/drafts')
|
|
.set('Cookie', cookieOf(user))
|
|
.send({ to: [], subject: '', body: '본문', bodyHtml: html })
|
|
.expect(201),
|
|
);
|
|
expect(drafts.get(saved.id)?.content.htmlBody).toBe(
|
|
`<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고" />`,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('받는 사람 추천 (/api/recipient-suggestions)', () => {
|
|
const ME = 'suggest.me@e2e.arasystem.test';
|
|
const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test';
|
|
const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test';
|
|
const suggest = (params: Record<string, string | number>) =>
|
|
request(server)
|
|
.get('/api/recipient-suggestions')
|
|
.query(params)
|
|
.set('Cookie', cookieOf(ME));
|
|
interface SuggestionPage {
|
|
items: { name: string | null; email: string; source: string }[];
|
|
pageInfo: {
|
|
hasNext: boolean;
|
|
nextCursor: string | null;
|
|
total: number;
|
|
};
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
// 사내 목록은 로그인에 성공한 직원으로 만든다
|
|
await login(COLLEAGUE_A);
|
|
await login(COLLEAGUE_B);
|
|
const now = new Date().toISOString();
|
|
correspondents.set(ME, [
|
|
{ name: '박서준', email: COLLEAGUE_B, at: now },
|
|
{
|
|
name: '협력사',
|
|
email: 'partner@vendor.e2e.arasystem.test',
|
|
at: now,
|
|
},
|
|
{ name: null, email: 'no-reply@notice.e2e.arasystem.test', at: now },
|
|
{ name: null, email: 'mail-gateway@e2e.arasystem.test', at: now },
|
|
{ name: '나', email: ME, at: now },
|
|
]);
|
|
});
|
|
|
|
it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
|
|
// 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다
|
|
const res = await suggest({
|
|
q: 'e2e.arasystem.test',
|
|
limit: 100,
|
|
}).expect(200);
|
|
|
|
expect(res.headers['cache-control']).toBe('private, no-store');
|
|
const { items } = dataOf<SuggestionPage>(res);
|
|
const emails = items.map((item) => item.email);
|
|
expect(items).toContainEqual({
|
|
name: '가나다',
|
|
email: COLLEAGUE_A,
|
|
source: 'directory',
|
|
});
|
|
// 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만
|
|
expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan(
|
|
emails.indexOf(COLLEAGUE_A),
|
|
);
|
|
expect(emails.filter((email) => email === COLLEAGUE_B)).toHaveLength(1);
|
|
expect(items[items.length - 1]).toEqual({
|
|
name: '협력사',
|
|
email: 'partner@vendor.e2e.arasystem.test',
|
|
source: 'recent',
|
|
});
|
|
expect(emails).not.toContain(ME);
|
|
expect(emails).not.toContain('mail-gateway@e2e.arasystem.test');
|
|
expect(emails).not.toContain('no-reply@notice.e2e.arasystem.test');
|
|
});
|
|
|
|
it('q 와 커서 페이지네이션 — 다른 q 의 커서 · 틀린 커서 · 100자 넘는 q · limit 범위 밖은 400 VAL_001', async () => {
|
|
const first = dataOf<SuggestionPage>(
|
|
await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200),
|
|
);
|
|
expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]);
|
|
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
|
const cursor = first.pageInfo.nextCursor ?? '';
|
|
|
|
const second = dataOf<SuggestionPage>(
|
|
await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200),
|
|
);
|
|
expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]);
|
|
expect(second.pageInfo).toEqual({
|
|
hasNext: false,
|
|
nextCursor: null,
|
|
total: 2,
|
|
});
|
|
|
|
const rejected: Record<string, string | number>[] = [
|
|
{ q: 'partner', cursor },
|
|
{ cursor: 'not-a-cursor' },
|
|
{ q: 'x'.repeat(101) },
|
|
{ limit: 101 },
|
|
];
|
|
for (const params of rejected) {
|
|
const res = await suggest(params).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
});
|
|
|
|
it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => {
|
|
const now = new Date().toISOString();
|
|
correspondents.set(ME, [
|
|
{
|
|
name: '새 거래처',
|
|
email: 'new@client.e2e.arasystem.test',
|
|
at: now,
|
|
},
|
|
]);
|
|
const emailsOf = async () =>
|
|
dataOf<SuggestionPage>(
|
|
await suggest({ q: 'client.e2e', limit: 100 }).expect(200),
|
|
).items.map((item) => item.email);
|
|
|
|
expect(await emailsOf()).toEqual([]);
|
|
|
|
await request(server)
|
|
.post('/api/messages')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ to: [DOYUN], subject: '제목', body: '본문' })
|
|
.expect(201);
|
|
expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절).
|
|
* 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 —
|
|
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다.
|
|
*/
|
|
describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => {
|
|
const ME = 'bulk.me@e2e.arasystem.test';
|
|
|
|
describe('새 메일 확인 (GET /api/mail-state)', () => {
|
|
it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => {
|
|
const res = await request(server)
|
|
.get('/api/mail-state')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(200);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { state: MAIL_STATE },
|
|
});
|
|
expect(res.headers['cache-control']).toBe('private, no-store');
|
|
});
|
|
});
|
|
|
|
describe('검색 (GET /api/messages 의 q)', () => {
|
|
const list = (params: Record<string, string | number>) =>
|
|
request(server)
|
|
.get('/api/messages')
|
|
.query(params)
|
|
.set('Cookie', cookieOf(ME));
|
|
|
|
it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => {
|
|
const before = listQueries.length;
|
|
|
|
const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200);
|
|
|
|
expect(dataOf<unknown>(res)).toEqual({
|
|
items: [],
|
|
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
|
|
});
|
|
expect(listQueries.slice(before)).toEqual([
|
|
{
|
|
user: ME,
|
|
query: {
|
|
mailboxId: undefined,
|
|
limit: 20,
|
|
cursor: undefined,
|
|
text: '회의 일정',
|
|
},
|
|
},
|
|
]);
|
|
});
|
|
|
|
it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => {
|
|
const before = listQueries.length;
|
|
|
|
await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200);
|
|
await list({ mailboxId: 'a' }).expect(200);
|
|
|
|
expect(
|
|
listQueries
|
|
.slice(before)
|
|
.map(({ query }) => [query.mailboxId, query.text]),
|
|
).toEqual([
|
|
['a', '"회의를 잡겠습니다"'],
|
|
['a', undefined],
|
|
]);
|
|
});
|
|
|
|
it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
|
const before = listQueries.length;
|
|
const words = (count: number) =>
|
|
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
|
|
|
|
const rejected: Record<string, string>[] = [
|
|
{},
|
|
{ q: ' ' },
|
|
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
|
{ q: words(MAX_SEARCH_WORDS + 1) },
|
|
{ mailboxId: '../a', q: '회의' },
|
|
];
|
|
for (const params of rejected) {
|
|
const res = await list(params).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
const twice = await request(server)
|
|
.get('/api/messages?q=a&q=b')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(400);
|
|
expect(body(twice).error?.code).toBe('VAL_001');
|
|
|
|
// 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다
|
|
await list({
|
|
q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `,
|
|
}).expect(200);
|
|
await list({ q: words(MAX_SEARCH_WORDS) }).expect(200);
|
|
expect(listQueries.length - before).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe('여러 통 바꾸기 (PATCH /api/messages)', () => {
|
|
const patch = (payload: object) =>
|
|
request(server)
|
|
.patch('/api/messages')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send(payload);
|
|
|
|
it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => {
|
|
const res = await patch({
|
|
ids: ['eaaaaab', GONE_MESSAGE],
|
|
isRead: true,
|
|
}).expect(200);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] },
|
|
});
|
|
expect(bulkUpdates.at(-1)).toEqual({
|
|
user: ME,
|
|
ids: ['eaaaaab', GONE_MESSAGE],
|
|
update: { isRead: true, isFlagged: undefined, mailboxId: undefined },
|
|
});
|
|
});
|
|
|
|
it('없는 메일함으로 옮기면 404 RES_001', async () => {
|
|
const res = await patch({
|
|
ids: ['eaaaaab'],
|
|
mailboxId: MISSING_MAILBOX,
|
|
}).expect(404);
|
|
|
|
expect(body(res).error?.code).toBe('RES_001');
|
|
});
|
|
|
|
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
|
|
const before = bulkUpdates.length;
|
|
const many = Array.from(
|
|
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
|
(_, index) => `m${index}`,
|
|
);
|
|
|
|
for (const payload of [
|
|
{ isRead: true },
|
|
{ ids: [], isRead: true },
|
|
{ ids: many, isRead: true },
|
|
{ ids: ['eaaaaab', 'eaaaaab'], isRead: true },
|
|
{ ids: ['../eaaaaab'], isRead: true },
|
|
{ ids: ['eaaaaab'] },
|
|
{ ids: ['eaaaaab'], isRead: null },
|
|
{ ids: ['eaaaaab'], isRead: true, isDeleted: true },
|
|
]) {
|
|
const res = await patch(payload).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(bulkUpdates).toHaveLength(before);
|
|
});
|
|
});
|
|
|
|
describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => {
|
|
const remove = (payload: object) =>
|
|
request(server)
|
|
.post('/api/message-deletions')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send(payload);
|
|
|
|
it('201 이 아니라 200 { deletedIds, skippedIds }', async () => {
|
|
const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect(
|
|
200,
|
|
);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] },
|
|
});
|
|
expect(trashDeletions.at(-1)).toEqual({
|
|
user: ME,
|
|
ids: ['eaaaaab', GONE_MESSAGE],
|
|
});
|
|
});
|
|
|
|
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => {
|
|
const before = trashDeletions.length;
|
|
|
|
for (const payload of [
|
|
{},
|
|
{ ids: [] },
|
|
{
|
|
ids: Array.from(
|
|
{ length: MAX_BULK_MESSAGE_IDS + 1 },
|
|
(_, index) => `m${index}`,
|
|
),
|
|
},
|
|
{ ids: ['eaaaaab', 'eaaaaab'] },
|
|
{ ids: ['eaaaaab', 'a b'] },
|
|
{ ids: ['eaaaaab'], permanent: true },
|
|
]) {
|
|
const res = await remove(payload).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(trashDeletions).toHaveLength(before);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('4단계 — 비밀번호 · 메일함 관리 · 부재중 자동 응답 · 스팸 · 원문', () => {
|
|
describe('비밀번호 바꾸기 (PUT /api/auth/password)', () => {
|
|
const NEW_PASSWORD = 'new-password-for-e2e';
|
|
|
|
/** 로그인한 두 쿠키로 보낸다 — 리프레시 쿠키(Path=/api/auth)가 이 세션을 알려 준다 */
|
|
const changeWith = (
|
|
session: { access: string; refresh: string },
|
|
payload: object,
|
|
ip: string,
|
|
) =>
|
|
request(server)
|
|
.put('/api/auth/password')
|
|
.set('X-Forwarded-For', ip)
|
|
.set(
|
|
'Cookie',
|
|
`access_token=${session.access}; refresh_token=${session.refresh}`,
|
|
)
|
|
.send(payload);
|
|
|
|
/** 액세스 쿠키만으로 보낸다 (형식 검사 · 한도 확인용) */
|
|
const changeAs = (email: string, payload: object, ip: string) =>
|
|
request(server)
|
|
.put('/api/auth/password')
|
|
.set('X-Forwarded-For', ip)
|
|
.set('Cookie', cookieOf(email))
|
|
.send(payload);
|
|
|
|
/** 리프레시 토큰이 가리키는 세션의 끝난 사유 */
|
|
const revokedReasonOf = async (refreshToken: string) => {
|
|
const { sid } = jwt.decode<{ sid: string }>(refreshToken);
|
|
const rows: { revoked_reason: string | null }[] =
|
|
await dataSource.query(
|
|
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
|
|
[sid],
|
|
);
|
|
return rows[0]?.revoked_reason;
|
|
};
|
|
|
|
it('바뀌면 200 data null — 이 세션만 남기고 그 직원의 다른 세션을 끊는다 (쿠키는 그대로)', async () => {
|
|
const email = 'pw.change@e2e.arasystem.test';
|
|
const mine = await login(email);
|
|
const other = await login(email);
|
|
|
|
const res = await changeWith(
|
|
mine,
|
|
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
|
'198.51.100.200',
|
|
).expect(200);
|
|
|
|
expect(body(res)).toEqual({ success: true, data: null });
|
|
expect(res.headers['set-cookie']).toBeUndefined();
|
|
expect(passwordChanges.at(-1)).toEqual({
|
|
email,
|
|
newPassword: NEW_PASSWORD,
|
|
});
|
|
expect(await revokedReasonOf(other.refresh)).toBe('password-changed');
|
|
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
|
|
|
await refreshWith(mine.refresh).expect(200);
|
|
const cut = await refreshWith(other.refresh).expect(401);
|
|
expect(body(cut).error?.code).toBe('AUTH_001');
|
|
});
|
|
|
|
it('지금 비밀번호가 틀리면 401 이 아니라 400 AUTH_005 — 세션은 그대로 · 엔진 규칙에 어긋난 새 비밀번호는 400 VAL_001', async () => {
|
|
const mine = await login('pw.wrong@e2e.arasystem.test');
|
|
|
|
const wrong = await changeWith(
|
|
mine,
|
|
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
|
'198.51.100.201',
|
|
).expect(400);
|
|
expect(body(wrong).error?.code).toBe('AUTH_005');
|
|
|
|
const rejected = await changeWith(
|
|
mine,
|
|
{
|
|
currentPassword: CORRECT_PASSWORD,
|
|
newPassword: ENGINE_REJECTED_PASSWORD,
|
|
},
|
|
'198.51.100.201',
|
|
).expect(400);
|
|
expect(body(rejected).error?.code).toBe('VAL_001');
|
|
|
|
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
|
await refreshWith(mine.refresh).expect(200);
|
|
});
|
|
|
|
it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면 400 AUTH_005 — 실패로 세지 않아 10번을 넘겨도 429 가 아니고, 세션은 그대로', async () => {
|
|
const email = 'pw.refused@e2e.arasystem.test';
|
|
const mine = await login(email);
|
|
const other = await login(email);
|
|
|
|
for (let i = 0; i < 12; i += 1) {
|
|
const res = await changeWith(
|
|
mine,
|
|
{
|
|
currentPassword: CORRECT_PASSWORD,
|
|
newPassword: ENGINE_REFUSED_PASSWORD,
|
|
},
|
|
'198.51.100.240',
|
|
).expect(400);
|
|
expect(body(res).error?.code).toBe('AUTH_005');
|
|
}
|
|
|
|
expect(await revokedReasonOf(other.refresh)).toBeNull();
|
|
await refreshWith(other.refresh).expect(200);
|
|
});
|
|
|
|
it('결과를 알 수 없게 끝나면 503 MAIL_001 — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 둔다', async () => {
|
|
const email = 'pw.unknown@e2e.arasystem.test';
|
|
const mine = await login(email);
|
|
const other = await login(email);
|
|
|
|
const res = await changeWith(
|
|
mine,
|
|
{
|
|
currentPassword: CORRECT_PASSWORD,
|
|
newPassword: ENGINE_UNKNOWN_PASSWORD,
|
|
},
|
|
'198.51.100.241',
|
|
).expect(503);
|
|
|
|
expect(body(res).error?.code).toBe('MAIL_001');
|
|
expect(await revokedReasonOf(other.refresh)).toBe(
|
|
'password-change-uncertain',
|
|
);
|
|
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
|
await refreshWith(mine.refresh).expect(200);
|
|
const cut = await refreshWith(other.refresh).expect(401);
|
|
expect(body(cut).error?.code).toBe('AUTH_001');
|
|
});
|
|
|
|
it('형식 오류는 400 VAL_001 — 7자 · 129자 · 지금과 같음 · 빠진 필드 · 모르는 필드, 엔진에 묻지 않는다', async () => {
|
|
const before = passwordChanges.length;
|
|
|
|
for (const payload of [
|
|
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(7) },
|
|
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(129) },
|
|
{ currentPassword: CORRECT_PASSWORD, newPassword: CORRECT_PASSWORD },
|
|
{ newPassword: NEW_PASSWORD },
|
|
{ currentPassword: '', newPassword: NEW_PASSWORD },
|
|
{
|
|
currentPassword: CORRECT_PASSWORD,
|
|
newPassword: NEW_PASSWORD,
|
|
confirmPassword: NEW_PASSWORD,
|
|
},
|
|
]) {
|
|
const res = await changeAs(
|
|
'pw.format@e2e.arasystem.test',
|
|
payload,
|
|
'198.51.100.202',
|
|
).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(passwordChanges).toHaveLength(before);
|
|
});
|
|
|
|
it('틀린 지금 비밀번호는 로그인 실패와 같은 한도 — 같은 주소로 10번이 넘으면 맞는 비밀번호도 429 REQ_001, 그 주소의 로그인도 막힌다', async () => {
|
|
const email = 'pw.locked@e2e.arasystem.test';
|
|
for (let i = 0; i < 10; i += 1) {
|
|
const res = await changeAs(
|
|
email,
|
|
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
|
`198.51.100.${210 + i}`,
|
|
).expect(400);
|
|
expect(body(res).error?.code).toBe('AUTH_005');
|
|
}
|
|
|
|
const locked = await changeAs(
|
|
email,
|
|
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
|
'198.51.100.230',
|
|
).expect(429);
|
|
expect(body(locked).error?.code).toBe('REQ_001');
|
|
|
|
const login = await request(server)
|
|
.post('/api/auth/login')
|
|
.set('X-Forwarded-For', '198.51.100.231')
|
|
.send({ email, password: CORRECT_PASSWORD })
|
|
.expect(429);
|
|
expect(body(login).error?.code).toBe('REQ_001');
|
|
});
|
|
});
|
|
|
|
describe('메일함 관리 (/api/mailboxes)', () => {
|
|
const ME = 'box.me@e2e.arasystem.test';
|
|
const send = (
|
|
method: 'post' | 'patch' | 'delete',
|
|
path: string,
|
|
payload?: object,
|
|
) => {
|
|
const req = request(server)[method](path).set('Cookie', cookieOf(ME));
|
|
return payload === undefined ? req : req.send(payload);
|
|
};
|
|
|
|
it('만들기는 201 Mailbox — 이름은 앞뒤 공백을 빼고, 상위가 없으면 맨 위(null)로 엔진에 넘긴다', async () => {
|
|
const res = await send('post', '/api/mailboxes', {
|
|
name: ' 거래처 ',
|
|
}).expect(201);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: {
|
|
id: 'newbox',
|
|
name: '거래처',
|
|
role: null,
|
|
parentId: null,
|
|
sortOrder: 0,
|
|
totalCount: 0,
|
|
unreadCount: 0,
|
|
},
|
|
});
|
|
expect(mailboxCalls.at(-1)).toEqual({
|
|
user: ME,
|
|
action: 'create',
|
|
args: [{ name: '거래처', parentId: null }],
|
|
});
|
|
|
|
await send('post', '/api/mailboxes', {
|
|
name: '하위',
|
|
parentId: 'a',
|
|
}).expect(201);
|
|
expect(mailboxCalls.at(-1)?.args).toEqual([
|
|
{ name: '하위', parentId: 'a' },
|
|
]);
|
|
});
|
|
|
|
it('이름 형식 · 상위 id 형식 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
|
const before = mailboxCalls.length;
|
|
|
|
for (const payload of [
|
|
{},
|
|
{ name: '' },
|
|
{ name: ' ' },
|
|
{ name: 'x'.repeat(65) },
|
|
{ name: '거래처/2026' },
|
|
{ name: '거래\t처' },
|
|
{ name: '거래처', parentId: '../a' },
|
|
{ name: '거래처', role: 'inbox' },
|
|
]) {
|
|
const res = await send('post', '/api/mailboxes', payload).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(mailboxCalls).toHaveLength(before);
|
|
});
|
|
|
|
it('같은 상위 안에 같은 이름은 409 MAIL_009, 없는 상위는 404 RES_001', async () => {
|
|
const taken = await send('post', '/api/mailboxes', {
|
|
name: TAKEN_MAILBOX_NAME,
|
|
}).expect(409);
|
|
expect(body(taken).error?.code).toBe('MAIL_009');
|
|
|
|
const orphan = await send('post', '/api/mailboxes', {
|
|
name: '거래처',
|
|
parentId: MISSING_MAILBOX,
|
|
}).expect(404);
|
|
expect(body(orphan).error?.code).toBe('RES_001');
|
|
});
|
|
|
|
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
|
|
const res = await send('patch', '/api/mailboxes/u1', {
|
|
name: ' 새 이름 ',
|
|
}).expect(200);
|
|
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
|
|
expect(mailboxCalls.at(-1)).toEqual({
|
|
user: ME,
|
|
action: 'rename',
|
|
args: ['u1', '새 이름'],
|
|
});
|
|
|
|
const malformed = await send('patch', '/api/mailboxes/a%20b', {
|
|
name: '새 이름',
|
|
}).expect(404);
|
|
expect(body(malformed).error?.code).toBe('RES_001');
|
|
|
|
const move = await send('patch', '/api/mailboxes/u1', {
|
|
name: '새 이름',
|
|
parentId: 'a',
|
|
}).expect(400);
|
|
expect(body(move).error?.code).toBe('VAL_001');
|
|
});
|
|
|
|
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
|
|
const res = await send('delete', '/api/mailboxes/u1').expect(200);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
|
|
});
|
|
expect(mailboxCalls.at(-1)).toEqual({
|
|
user: ME,
|
|
action: 'delete',
|
|
args: ['u1'],
|
|
});
|
|
});
|
|
|
|
it('전체 읽음은 { isRead: true } 만 받는다 — 200 { processed, remaining }', async () => {
|
|
const res = await send('patch', '/api/mailboxes/a/messages', {
|
|
isRead: true,
|
|
}).expect(200);
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { processed: 5, remaining: 0 },
|
|
});
|
|
expect(mailboxCalls.at(-1)).toEqual({
|
|
user: ME,
|
|
action: 'mark-read',
|
|
args: ['a'],
|
|
});
|
|
|
|
const before = mailboxCalls.length;
|
|
for (const payload of [
|
|
{ isRead: false },
|
|
{},
|
|
{ isRead: true, isFlagged: true },
|
|
]) {
|
|
const rejected = await send(
|
|
'patch',
|
|
'/api/mailboxes/a/messages',
|
|
payload,
|
|
).expect(400);
|
|
expect(body(rejected).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(mailboxCalls).toHaveLength(before);
|
|
});
|
|
|
|
it('비우기는 200 { processed, remaining }', async () => {
|
|
const res = await send('delete', '/api/mailboxes/b/messages').expect(
|
|
200,
|
|
);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: { processed: 2, remaining: 0 },
|
|
});
|
|
expect(mailboxCalls.at(-1)).toEqual({
|
|
user: ME,
|
|
action: 'empty',
|
|
args: ['b'],
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 같은 메일함의 처리 중 잠금 — 대역 엔진의 SLOW_MAILBOX 비우기를 문으로 붙잡아 둔 채 다음 요청을 보낸다.
|
|
* 붙잡힌 요청이 있는 동안 supertest 가 서버를 닫지 않게 서버를 미리 열어 둔다 (로그인 동시 시도 테스트와 같은 이유)
|
|
*/
|
|
it(
|
|
'같은 직원 · 같은 메일함의 오래 걸리는 처리가 처리 중이면 종류가 달라도 엔진에 묻지 않고 429 REQ_001 — 다른 메일함은 그대로, 끝나면 다시 받는다',
|
|
async () => {
|
|
const httpServer = app.getHttpServer();
|
|
const startedServer = !httpServer.listening;
|
|
if (startedServer) await listenOnAnyPort(httpServer);
|
|
|
|
const gate = createGate();
|
|
slowMailboxGate = gate.opened;
|
|
const before = mailboxCalls.length;
|
|
const first = send(
|
|
'delete',
|
|
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
|
).then((res) => res);
|
|
|
|
try {
|
|
await waitUntil(
|
|
() => mailboxCalls.length > before,
|
|
GATE_DEADLINE_MS,
|
|
);
|
|
expect(mailboxCalls.slice(before)).toEqual([
|
|
{ user: ME, action: 'empty', args: [SLOW_MAILBOX] },
|
|
]);
|
|
|
|
for (const [method, path, payload] of [
|
|
['delete', `/api/mailboxes/${SLOW_MAILBOX}/messages`, undefined],
|
|
[
|
|
'patch',
|
|
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
|
{ isRead: true },
|
|
],
|
|
['delete', `/api/mailboxes/${SLOW_MAILBOX}`, undefined],
|
|
] as const) {
|
|
const busy = await send(method, path, payload).expect(429);
|
|
expect(body(busy).error?.code).toBe('REQ_001');
|
|
}
|
|
expect(mailboxCalls).toHaveLength(before + 1);
|
|
|
|
// 같은 때 같은 IP 에서 다른 메일함은 된다 — 위의 429 는 요청 수 한도가 아니다
|
|
await send('patch', '/api/mailboxes/a/messages', {
|
|
isRead: true,
|
|
}).expect(200);
|
|
} finally {
|
|
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트 · 앱 종료가 멈추지 않게
|
|
gate.open();
|
|
slowMailboxGate = null;
|
|
await Promise.allSettled([first]);
|
|
if (startedServer) await closeServer(httpServer);
|
|
}
|
|
|
|
expect((await first).status).toBe(200);
|
|
await send(
|
|
'delete',
|
|
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
|
).expect(200);
|
|
},
|
|
GATED_TEST_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
describe('부재중 자동 응답 (/api/mail-vacation)', () => {
|
|
const ME = 'vacation.me@e2e.arasystem.test';
|
|
const vacation = {
|
|
isEnabled: true,
|
|
fromDate: '2026-11-02T15:00:00.000Z',
|
|
toDate: '2026-11-07T15:00:00.000Z',
|
|
subject: '부재중입니다',
|
|
textBody: '11월 7일까지 휴가입니다.',
|
|
};
|
|
const put = (payload: object) =>
|
|
request(server)
|
|
.put('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send(payload);
|
|
|
|
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => {
|
|
const res = await request(server)
|
|
.get('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(200);
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: NEVER_CONFIGURED_VACATION,
|
|
});
|
|
|
|
const restored = await put(dataOf<object>(res)).expect(200);
|
|
expect(dataOf<unknown>(restored)).toEqual(NEVER_CONFIGURED_VACATION);
|
|
});
|
|
|
|
it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => {
|
|
const res = await put(vacation).expect(200);
|
|
expect(dataOf<unknown>(res)).toEqual(vacation);
|
|
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation });
|
|
|
|
const read = await request(server)
|
|
.get('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(200);
|
|
expect(dataOf<unknown>(read)).toEqual(vacation);
|
|
});
|
|
|
|
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과(켤 때 150 · 600자, 꺼 둔 채로 200 · 4,000자), 서식 본문은 400 VAL_001 — 저장하지 않는다', async () => {
|
|
const before = vacationSaves.length;
|
|
const withoutToDate = Object.fromEntries(
|
|
Object.entries(vacation).filter(([key]) => key !== 'toDate'),
|
|
);
|
|
|
|
for (const payload of [
|
|
{ ...vacation, subject: '' },
|
|
{ ...vacation, textBody: '' },
|
|
{ ...vacation, toDate: vacation.fromDate },
|
|
{ ...vacation, toDate: '2026-11-01T15:00:00.000Z' },
|
|
{ ...vacation, fromDate: '2026-11-02' },
|
|
{ ...vacation, fromDate: '2026-11-02T15:00:00+09:00' },
|
|
withoutToDate,
|
|
{ ...vacation, subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH + 1) },
|
|
{ ...vacation, textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1) },
|
|
{
|
|
...vacation,
|
|
isEnabled: false,
|
|
subject: 'x'.repeat(MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF + 1),
|
|
},
|
|
{
|
|
...vacation,
|
|
isEnabled: false,
|
|
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH_WHEN_OFF + 1),
|
|
},
|
|
{ ...vacation, htmlBody: '<p>휴가</p>' },
|
|
{ ...vacation, subject: ' ' },
|
|
{ ...vacation, textBody: ' \n\t ' },
|
|
{ ...vacation, subject: '부재\r\n중' },
|
|
{ ...vacation, textBody: '휴가\u0000' },
|
|
{ ...vacation, textBody: '휴가\u001b[2J' },
|
|
{
|
|
...vacation,
|
|
fromDate: '2026-11-02T15:00:00.100Z',
|
|
toDate: '2026-11-02T15:00:00.900Z',
|
|
},
|
|
]) {
|
|
const res = await put(payload).expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(vacationSaves).toHaveLength(before);
|
|
});
|
|
|
|
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지는 한글로 채워도 저장한다 (엔진의 UTF-8 바이트 한도 안 — 계약 2절 "길이 한도의 근거")`, async () => {
|
|
const full = {
|
|
...vacation,
|
|
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
|
|
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
|
|
};
|
|
|
|
const res = await put(full).expect(200);
|
|
|
|
expect(dataOf<unknown>(res)).toEqual(full);
|
|
});
|
|
|
|
it('꺼 둔 채로는 4단계 한도까지 받는다 — 4단계에 저장해 둔 영문 내용 1,000자도 끌 수 있고(200), 그대로 켜면 400 VAL_001 (줄인 뒤 켠다)', async () => {
|
|
const longOff = {
|
|
...vacation,
|
|
isEnabled: false,
|
|
textBody: 'x'.repeat(1_000),
|
|
};
|
|
|
|
const res = await put(longOff).expect(200);
|
|
expect(dataOf<unknown>(res)).toEqual(longOff);
|
|
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: longOff });
|
|
|
|
const before = vacationSaves.length;
|
|
for (const enabling of [
|
|
{ ...longOff, isEnabled: true },
|
|
{
|
|
...longOff,
|
|
isEnabled: true,
|
|
textBody: 'x'.repeat(MAX_VACATION_TEXT_LENGTH + 1),
|
|
},
|
|
]) {
|
|
const rejected = await put(enabling).expect(400);
|
|
expect(body(rejected).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(vacationSaves).toHaveLength(before);
|
|
});
|
|
|
|
it('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => {
|
|
const off = {
|
|
...vacation,
|
|
isEnabled: false,
|
|
fromDate: '2026-11-02T15:00:00.750Z',
|
|
toDate: '2026-11-07T15:00:00.250Z',
|
|
subject: ' ',
|
|
textBody: '안녕하세요.\r\n\t11월 7일까지 휴가입니다.\n',
|
|
};
|
|
|
|
const res = await put(off).expect(200);
|
|
|
|
const saved = {
|
|
...off,
|
|
fromDate: '2026-11-02T15:00:00.000Z',
|
|
toDate: '2026-11-07T15:00:00.000Z',
|
|
};
|
|
expect(dataOf<unknown>(res)).toEqual(saved);
|
|
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved });
|
|
});
|
|
});
|
|
|
|
describe('스팸 신고 · 스팸 아님 (isJunk)', () => {
|
|
const ME = 'junk.me@e2e.arasystem.test';
|
|
|
|
it('한 통 PATCH 는 이동과 함께 isJunk 를 엔진에 넘긴다 — true · false 가 아니면 400 VAL_001', async () => {
|
|
await request(server)
|
|
.patch('/api/messages/eaaaaab')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ mailboxId: 'c', isJunk: true })
|
|
.expect(200);
|
|
expect(singleUpdates.at(-1)).toEqual({
|
|
user: ME,
|
|
id: 'eaaaaab',
|
|
update: { mailboxId: 'c', isJunk: true },
|
|
});
|
|
|
|
const before = singleUpdates.length;
|
|
for (const isJunk of ['yes', null, 1]) {
|
|
const res = await request(server)
|
|
.patch('/api/messages/eaaaaab')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ isJunk })
|
|
.expect(400);
|
|
expect(body(res).error?.code).toBe('VAL_001');
|
|
}
|
|
expect(singleUpdates).toHaveLength(before);
|
|
});
|
|
|
|
it('여러 통 PATCH 도 isJunk 만으로 바꿀 수 있다 (스팸 아님)', async () => {
|
|
await request(server)
|
|
.patch('/api/messages')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ ids: ['eaaaaab'], mailboxId: 'a', isJunk: false })
|
|
.expect(200);
|
|
|
|
expect(bulkUpdates.at(-1)).toEqual({
|
|
user: ME,
|
|
ids: ['eaaaaab'],
|
|
update: { mailboxId: 'a', isJunk: false },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('원문 내려받기 (GET /api/messages/{id}/source)', () => {
|
|
const ME = 'source.me@e2e.arasystem.test';
|
|
|
|
it('표준 래퍼 없이 message/rfc822 원문 — attachment(제목으로 만든 .eml) · private, no-store · nosniff', async () => {
|
|
const res = await request(server)
|
|
.get('/api/messages/eaaaaab/source')
|
|
.set('Cookie', cookieOf(ME))
|
|
.responseType('blob')
|
|
.expect(200);
|
|
|
|
expect(res.headers['content-type']).toBe('message/rfc822');
|
|
expect(res.headers['content-disposition']).toBe(
|
|
buildAttachmentDisposition('Re_ 견적서_10월.eml'),
|
|
);
|
|
expect(res.headers['cache-control']).toBe('private, no-store');
|
|
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
|
expect(res.headers['content-length']).toBe(
|
|
String(Buffer.byteLength(RAW_MESSAGE, 'utf8')),
|
|
);
|
|
expect((res.body as Buffer).toString('utf8')).toBe(RAW_MESSAGE);
|
|
});
|
|
|
|
it('없는 메일 · 형식이 틀린 id 는 표준 실패 형식의 404 RES_001', async () => {
|
|
for (const id of [GONE_MESSAGE, 'a%20b']) {
|
|
const res = await request(server)
|
|
.get(`/api/messages/${id}/source`)
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(404);
|
|
expect(body(res).error?.code).toBe('RES_001');
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 5단계 — 실시간 알림 (계약 2절 "실시간 알림").
|
|
* 엔진 연결(다시 잇기 · 무소식 감시 · 새 메일 계산)과 연결 관리(나눠 쓰기 · 30초 · 토큰 만료 · 종료)는 단위 테스트가 본다 —
|
|
* 여기서는 실제 HTTP 의 머리글 · 이벤트 형식 · 연결 전 실패의 표준 JSON 을 본다.
|
|
* 응답이 끝나지 않으므로 서버를 미리 열어 http.get 으로 읽는다 (로그인 동시 시도 테스트와 같은 이유로 테스트가 끝나면 닫는다)
|
|
*/
|
|
describe('5단계 — 실시간 알림 (GET /api/mail-events)', () => {
|
|
/** 받은 글자에 빈 줄로 끝난 이벤트가 n 개 쌓일 때까지 기다린다 */
|
|
const eventsIn = (text: string) =>
|
|
text.split('\n\n').filter((block) => block !== '');
|
|
const waitForEvents = (stream: EventStreamClient, count: number) =>
|
|
waitUntil(
|
|
() =>
|
|
stream.received().endsWith('\n\n') &&
|
|
eventsIn(stream.received()).length >= count,
|
|
GATE_DEADLINE_MS,
|
|
);
|
|
|
|
/** 서버를 미리 열고, 끝나면 연 연결을 모두 끊고 닫는다 */
|
|
async function withListeningServer(
|
|
run: (httpServer: Server, opened: EventStreamClient[]) => Promise<void>,
|
|
): Promise<void> {
|
|
const httpServer = app.getHttpServer();
|
|
const startedServer = !httpServer.listening;
|
|
if (startedServer) await listenOnAnyPort(httpServer);
|
|
const opened: EventStreamClient[] = [];
|
|
try {
|
|
await run(httpServer, opened);
|
|
} finally {
|
|
for (const stream of opened) stream.close();
|
|
if (startedServer) await closeServer(httpServer);
|
|
}
|
|
}
|
|
|
|
it(
|
|
'200 text/event-stream — 머리글(charset · no-cache, no-transform · X-Accel-Buffering: no)과 처음 state, 그 뒤 엔진 소식을 이어서 보낸다',
|
|
async () => {
|
|
const ME = 'events.me@e2e.arasystem.test';
|
|
await withListeningServer(async (httpServer, opened) => {
|
|
const stream = await openEventStream(httpServer, cookieOf(ME));
|
|
opened.push(stream);
|
|
await waitForEvents(stream, 1);
|
|
|
|
const { headers, statusCode } = stream.response;
|
|
expect(statusCode).toBe(200);
|
|
expect(headers['content-type']).toBe(
|
|
'text/event-stream; charset=utf-8',
|
|
);
|
|
expect(headers['cache-control']).toBe('no-cache, no-transform');
|
|
expect(headers['x-accel-buffering']).toBe('no');
|
|
expect(typeof headers['x-request-id']).toBe('string');
|
|
// 표준 래퍼 없이 — 상태 값은 GET /api/mail-state 와 같다
|
|
expect(stream.received()).toBe(
|
|
`event: state\ndata: ${JSON.stringify({ state: MAIL_STATE, inboxUnread: 2 })}\n\n`,
|
|
);
|
|
|
|
const watch = liveWatches.find(({ user }) => user === ME);
|
|
expect(watch).toBeDefined();
|
|
const newMail = {
|
|
count: 1,
|
|
messages: [
|
|
{
|
|
id: 'm1',
|
|
from: {
|
|
name: '박서준',
|
|
email: 'seojun.park@e2e.arasystem.test',
|
|
},
|
|
subject: '줄\n바꿈이 있는 제목',
|
|
receivedAt: '2026-10-28T05:42:00.000Z',
|
|
},
|
|
],
|
|
};
|
|
watch?.listener.state({ state: 'c2Iz.c2Fr', inboxUnread: 3 });
|
|
watch?.listener.newMail(newMail);
|
|
await waitForEvents(stream, 3);
|
|
|
|
expect(eventsIn(stream.received()).slice(1)).toEqual([
|
|
`event: state\ndata: ${JSON.stringify({ state: 'c2Iz.c2Fr', inboxUnread: 3 })}`,
|
|
`event: new-mail\ndata: ${JSON.stringify(newMail)}`,
|
|
]);
|
|
});
|
|
},
|
|
GATED_TEST_TIMEOUT_MS,
|
|
);
|
|
|
|
it('처음 연결 때 엔진에 연결할 수 없으면 스트림을 열지 않고 503 MAIL_001 (표준 실패 JSON)', async () => {
|
|
const res = await request(server)
|
|
.get('/api/mail-events')
|
|
.set('Cookie', cookieOf(EVENTS_ENGINE_DOWN))
|
|
.expect(503);
|
|
|
|
expect(res.headers['content-type']).toMatch(/^application\/json/);
|
|
expect(body(res).success).toBe(false);
|
|
expect(body(res).error?.code).toBe('MAIL_001');
|
|
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
|
});
|
|
|
|
it(
|
|
'한 직원의 연결이 5개를 넘으면 429 REQ_001 (표준 실패 JSON) — 엔진 연결은 하나를 나눠 쓴다',
|
|
async () => {
|
|
const ME = 'events.limit@e2e.arasystem.test';
|
|
await withListeningServer(async (httpServer, opened) => {
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const stream = await openEventStream(httpServer, cookieOf(ME));
|
|
opened.push(stream);
|
|
await waitForEvents(stream, 1);
|
|
expect(stream.response.statusCode).toBe(200);
|
|
}
|
|
|
|
const res = await request(httpServer)
|
|
.get('/api/mail-events')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(429);
|
|
expect(body(res).error?.code).toBe('REQ_001');
|
|
expect(liveWatches.filter(({ user }) => user === ME)).toHaveLength(1);
|
|
});
|
|
},
|
|
GATED_TEST_TIMEOUT_MS,
|
|
);
|
|
});
|
|
|
|
/**
|
|
* 받는 사람별 발송 결과 — 6단계 앞 수정 (계약 2절 "받는 사람별 결과").
|
|
* 엔진 쪽 판단(SMTP 응답 분류 · 모두 거절일 때 보낸 사본 지우기)은 대역 엔진이 흉내 낸다 (ENGINE_REJECTIONS) —
|
|
* 여기서는 HTTP 형식 · 이어 쓴 저장본 · 같은 키 잠금을 본다
|
|
*/
|
|
describe('받는 사람별 발송 결과 (POST /api/messages — 6단계 앞 수정)', () => {
|
|
const ME = 'delivery.me@e2e.arasystem.test';
|
|
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
|
const address = (email: string) => ({ name: null, email });
|
|
|
|
const send = (payload: object, key?: string) => {
|
|
const sending = request(server)
|
|
.post('/api/messages')
|
|
.set('Cookie', cookieOf(ME));
|
|
return (key ? sending.set('Idempotency-Key', key) : sending).send({
|
|
subject: '제목',
|
|
body: '본문',
|
|
...payload,
|
|
});
|
|
};
|
|
/** 이어 쓸 저장본을 하나 만든다 */
|
|
const savedDraft = async () =>
|
|
dataOf<SavedDraft>(
|
|
await request(server)
|
|
.post('/api/drafts')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ to: [DOYUN], subject: '쓰는 중', body: '본문' })
|
|
.expect(201),
|
|
).id;
|
|
|
|
it('모두 받았으면 201 { id, rejectedRecipients: [] }', async () => {
|
|
const res = await send({ to: [DOYUN] }).expect(201);
|
|
|
|
expect(body(res)).toEqual({
|
|
success: true,
|
|
data: {
|
|
id: expect.stringMatching(/^sent\d+$/) as string,
|
|
rejectedRecipients: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('일부만 받지 않았으면 보낸 것이다 — 201 에 받지 않은 사람(요청에 쓴 주소 · 까닭), 이어 쓴 저장본은 지운다', async () => {
|
|
const draftId = await savedDraft();
|
|
|
|
const res = await send({
|
|
to: [DOYUN],
|
|
cc: [address(NOBODY)],
|
|
bcc: [address(BUSY)],
|
|
draftId,
|
|
}).expect(201);
|
|
|
|
expect(dataOf<unknown>(res)).toEqual({
|
|
id: expect.stringMatching(/^sent\d+$/) as string,
|
|
rejectedRecipients: [
|
|
{ email: NOBODY, reason: 'not-found' },
|
|
{ email: BUSY, reason: 'temporary' },
|
|
],
|
|
});
|
|
expect(deletedDrafts).toContain(draftId);
|
|
});
|
|
|
|
it('아무도 받지 않았으면(5xx 가 섞임) 400 MAIL_010 — 표준 실패 JSON, 이어 쓴 저장본은 그대로 둔다', async () => {
|
|
const draftId = await savedDraft();
|
|
const sentBefore = sentMessages.length;
|
|
|
|
const res = await send({
|
|
to: [address(NOBODY)],
|
|
cc: [address(BUSY)],
|
|
draftId,
|
|
}).expect(400);
|
|
|
|
expect(body(res).success).toBe(false);
|
|
expect(body(res).error?.code).toBe('MAIL_010');
|
|
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
|
expect(sentMessages).toHaveLength(sentBefore);
|
|
expect(deletedDrafts).not.toContain(draftId);
|
|
expect(drafts.has(draftId)).toBe(true);
|
|
});
|
|
|
|
it('모두 4xx(지금은 받을 수 없음)면 503 MAIL_011 — 이어 쓴 저장본은 그대로 둔다', async () => {
|
|
const draftId = await savedDraft();
|
|
|
|
const res = await send({ to: [address(BUSY)], draftId }).expect(503);
|
|
|
|
expect(body(res).error?.code).toBe('MAIL_011');
|
|
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
|
|
expect(deletedDrafts).not.toContain(draftId);
|
|
});
|
|
|
|
it('모두 받지 않은 키는 잠가 두지 않는다 — 곧바로 같은 키로 다시 보내면 새로 보낸다 (429 · 409 가 아니다)', async () => {
|
|
for (const [rejected, status] of [
|
|
[NOBODY, 400],
|
|
[BUSY, 503],
|
|
] as const) {
|
|
const key = randomUUID();
|
|
const refusedBefore = refusedSends.length;
|
|
|
|
await send({ to: [address(rejected)] }, key).expect(status);
|
|
// 같은 내용으로 다시 — 처리 중(REQ_001)이 아니라 엔진에 다시 묻는다
|
|
await send({ to: [address(rejected)] }, key).expect(status);
|
|
expect(refusedSends).toHaveLength(refusedBefore + 2);
|
|
|
|
// 주소를 고쳐 같은 키로 — 보낸 사본이 없으니 내용이 달라도 MAIL_005 가 아니다
|
|
await send({ to: [DOYUN] }, key).expect(201);
|
|
}
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 6단계 — 메일 필터 (계약 2절 "메일 필터").
|
|
* 엔진 쪽(만드는 Sieve · 머리의 원본 · 지문 · 조건부 쓰기 · 다시 시도)은 jmap/*.spec.ts 가 본다 —
|
|
* 여기서는 HTTP 형식 · 요청 검사 · 부재중 자동 응답과 같은 저장 자리를 쓰는지를 본다 (대역 엔진이 둘을 한 곳에 둔다)
|
|
*/
|
|
describe('6단계 — 메일 필터', () => {
|
|
describe('메일 필터 (/api/mail-filters)', () => {
|
|
const ME = 'filter.me@e2e.arasystem.test';
|
|
const rule = {
|
|
id: '0b9a4f9e-7c2d-4d3a-9f4e-1a2b3c4d5e6f',
|
|
name: '거래처 메일',
|
|
isEnabled: true,
|
|
match: 'any',
|
|
conditions: [
|
|
{
|
|
field: 'from',
|
|
operator: 'contains',
|
|
value: '@hanbit-partners.co.kr',
|
|
},
|
|
],
|
|
actions: {
|
|
moveToMailboxId: 'j',
|
|
markAsRead: true,
|
|
flag: false,
|
|
forwardTo: 'doyun.lee@e2e.arasystem.test',
|
|
},
|
|
};
|
|
const get = () =>
|
|
request(server).get('/api/mail-filters').set('Cookie', cookieOf(ME));
|
|
const put = (payload: object) =>
|
|
request(server)
|
|
.put('/api/mail-filters')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send(payload);
|
|
const versionNow = async () =>
|
|
dataOf<{ version: string }>(await get().expect(200)).version;
|
|
|
|
it('처음에는 규칙 없음 · 동작함 — 받은 version 으로 저장하면 200(글은 앞뒤 공백을 지워서), 다음 조회도 그 규칙', async () => {
|
|
const first = await get().expect(200);
|
|
expect(body(first).success).toBe(true);
|
|
const initial = dataOf<{
|
|
rules: unknown[];
|
|
version: string;
|
|
isActive: boolean;
|
|
}>(first);
|
|
expect(initial).toEqual({
|
|
rules: [],
|
|
version: expect.any(String) as string,
|
|
isActive: true,
|
|
});
|
|
|
|
const res = await put({
|
|
rules: [
|
|
{
|
|
...rule,
|
|
name: ' 거래처 메일 ',
|
|
conditions: [
|
|
{ ...rule.conditions[0], value: ' @hanbit-partners.co.kr ' },
|
|
],
|
|
},
|
|
],
|
|
version: initial.version,
|
|
}).expect(200);
|
|
const saved = dataOf<{ rules: unknown[]; version: string }>(res);
|
|
expect(saved.rules).toEqual([rule]);
|
|
expect(saved.version).not.toBe(initial.version);
|
|
expect(filterSaves.at(-1)?.user).toBe(ME);
|
|
|
|
const read = dataOf<{ rules: unknown[]; version: string }>(
|
|
await get().expect(200),
|
|
);
|
|
expect(read).toEqual({
|
|
rules: [rule],
|
|
version: saved.version,
|
|
isActive: true,
|
|
});
|
|
});
|
|
|
|
it('옛 version 으로 다른 규칙을 저장하면 409 MAIL_012 — 저장하지 않는다. 같은 규칙을 다시 보내면 그대로 200', async () => {
|
|
const before = await versionNow();
|
|
await put({
|
|
rules: [{ ...rule, id: 'other-tab-rule' }],
|
|
version: before,
|
|
}).expect(200);
|
|
|
|
const conflict = await put({
|
|
rules: [{ ...rule, id: 'my-rule' }],
|
|
version: before,
|
|
}).expect(409);
|
|
expect(body(conflict).error?.code).toBe('MAIL_012');
|
|
expect(
|
|
dataOf<{ rules: { id: string }[] }>(await get().expect(200)).rules,
|
|
).toEqual([expect.objectContaining({ id: 'other-tab-rule' })]);
|
|
|
|
// 결과를 모르는 실패 뒤의 재시도 — 저장해 둔 규칙과 같으면 옛 version 이어도 저장한다
|
|
await put({
|
|
rules: [{ ...rule, id: 'other-tab-rule' }],
|
|
version: before,
|
|
}).expect(200);
|
|
});
|
|
|
|
it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
|
const version = await versionNow();
|
|
const before = filterSaves.length;
|
|
const withRule = (overrides: object) => ({
|
|
rules: [{ ...rule, ...overrides }],
|
|
version,
|
|
});
|
|
const withActions = (overrides: object) =>
|
|
withRule({ actions: { ...rule.actions, ...overrides } });
|
|
const withValue = (value: unknown) =>
|
|
withRule({ conditions: [{ ...rule.conditions[0], value }] });
|
|
|
|
const payloads = [
|
|
{ rules: [rule] },
|
|
{ rules: [rule], version, isActive: true },
|
|
{
|
|
rules: Array.from({ length: 51 }, (_, i) => ({
|
|
...rule,
|
|
id: `r${i}`,
|
|
})),
|
|
version,
|
|
},
|
|
{ rules: [rule, rule], version },
|
|
withRule({ id: '규칙 하나' }),
|
|
withRule({ name: 'x'.repeat(51) }),
|
|
withRule({ match: 'always' }),
|
|
withRule({ match: 'all', conditions: [] }),
|
|
withRule({
|
|
conditions: Array.from({ length: 6 }, () => rule.conditions[0]),
|
|
}),
|
|
withRule({ priority: 1 }),
|
|
withValue(''),
|
|
withValue(' '),
|
|
withValue('x'.repeat(201)),
|
|
withValue('광고${x}'),
|
|
withValue('광고\n줄바꿈'),
|
|
withValue('탭\t가운데'),
|
|
withActions({
|
|
moveToMailboxId: null,
|
|
markAsRead: false,
|
|
flag: false,
|
|
forwardTo: null,
|
|
}),
|
|
withActions({ forwardTo: 'someone@gmail.com' }),
|
|
withActions({ forwardTo: 'filter.me@e2e.arasystem.test' }),
|
|
withActions({ forwardTo: '${x}@e2e.arasystem.test' }),
|
|
withActions({ forwardTo: 'not-an-address' }),
|
|
withActions({ moveToMailboxId: 'e' }),
|
|
withActions({ moveToMailboxId: 'd' }),
|
|
];
|
|
// 몇 번째 본문이 어긋났는지 보이게 모두 보낸 뒤 견준다
|
|
const answers: string[] = [];
|
|
for (const payload of payloads) {
|
|
const res = await put(payload);
|
|
answers.push(`${res.status} ${body(res).error?.code ?? ''}`);
|
|
}
|
|
expect(answers).toEqual(payloads.map(() => '400 VAL_001'));
|
|
expect(filterSaves).toHaveLength(before);
|
|
|
|
// 지운 메일함을 가리키는 규칙은 받는다 — 그 메일함이 없으면 받은편지함에 들어온다
|
|
await put(withActions({ moveToMailboxId: 'deletedbox' })).expect(200);
|
|
});
|
|
|
|
it('만든 엔진 규칙이 엔진 한도를 넘으면 400 MAIL_013', async () => {
|
|
const res = await put({
|
|
rules: [
|
|
{
|
|
...rule,
|
|
conditions: [
|
|
{
|
|
...rule.conditions[0],
|
|
value: ENGINE_TOO_LARGE_FILTER_VALUE,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
version: await versionNow(),
|
|
}).expect(400);
|
|
expect(body(res).error?.code).toBe('MAIL_013');
|
|
});
|
|
|
|
it('필터를 저장해도 부재중 자동 응답은 그대로다 — 같은 엔진 규칙에 함께 두고, 부재중 저장도 필터를 지킨다', async () => {
|
|
const vacation = {
|
|
isEnabled: true,
|
|
fromDate: '2026-11-02T15:00:00.000Z',
|
|
toDate: '2026-11-07T15:00:00.000Z',
|
|
subject: '부재중입니다',
|
|
textBody: '11월 7일까지 휴가입니다.',
|
|
};
|
|
await request(server)
|
|
.put('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send(vacation)
|
|
.expect(200);
|
|
|
|
await put({
|
|
rules: [{ ...rule, isEnabled: false }],
|
|
version: await versionNow(),
|
|
}).expect(200);
|
|
|
|
const read = await request(server)
|
|
.get('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.expect(200);
|
|
expect(dataOf<unknown>(read)).toEqual(vacation);
|
|
|
|
await request(server)
|
|
.put('/api/mail-vacation')
|
|
.set('Cookie', cookieOf(ME))
|
|
.send({ ...vacation, isEnabled: false })
|
|
.expect(200);
|
|
expect(
|
|
dataOf<{ rules: unknown[] }>(await get().expect(200)).rules,
|
|
).toEqual([{ ...rule, isEnabled: false }]);
|
|
});
|
|
});
|
|
});
|
|
|
|
/**
|
|
* 출처 검사(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'],
|
|
['patch', '/api/messages'],
|
|
['delete', '/api/messages/eaaaaab'],
|
|
['post', '/api/message-deletions'],
|
|
['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);
|
|
});
|
|
});
|
|
});
|