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_CONTACTS } from '@/modules/contacts/contacts.constants'; import { CONTACTS_PROVIDER, DIRECTORY_PROVIDER, type AddressBookCard, type AddressBookChanges, type AddressBookContactCard, type AddressBookGroupCard, type AddressBookSnapshot, type AddressBookWriteOutcome, type DirectoryAccount, type RemovedCardOriginal, } from '@/modules/contacts/contacts.provider'; 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_IMAGE_RESOLVER, MAIL_IMAGE_TRANSPORT, type MailImageRequest, type MailImageResponse, type ResolvedAddress, } from '@/modules/mail/images/mail-image-fetcher'; import { MAIL_PROVIDER } from '@/modules/mail/mail.provider'; import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings'; import type { BlockedSender, Correspondence, DraftContent, DraftSource, Mailbox, MailboxBulkResult, MailboxCreate, MailboxDeletion, MailBlockAdded, MailBlockList, MailFilterRule, MailFilterRunOutcome, MailFilterRunRequest, MailFilterSet, MailFilterUpdate, MailVacation, MailVacationState, MailWatch, MailWatchListener, MessageDeletionResult, MessageDetailSource, MessageListQuery, MessageSource, MessagesPatchResult, MessageSummary, MessageUpdate, OutgoingMessage, PasswordChangeOutcome, PendingSend, 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 = (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'; /** 사내 목록 계정 — 로그인할 수 없고 엔진의 계정 목록을 읽는 데만 쓴다 (mail.settings.ts 의 directoryAccount — 8단계) */ const DIRECTORY_ACCOUNT = 'mail-directory@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([ [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, })); /** 대역 메일 엔진이 "다른 규칙의 cursor"라고 답하는 cursor (400 VAL_001 경로 — 실제 해석은 jmap/filter-run-cursor.ts) */ const OTHER_RULE_CURSOR = 'otherrulecursor'; /** 대역 엔진의 규칙 지문 — 실제 지문은 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: '', }; /** 9단계 — 대역 메일 엔진이 "이미 보냈다(엔진이 풀었다)"고 답하는 메일 id (409 MAIL_014 경로) */ const ALREADY_RELEASED_MESSAGE = 'alreadyreleased'; /** * 9단계 — 대역 메일 엔진이 붙잡고 있는(보내기 취소 · 예약을 기다리는) 메일 id — 옮기기 · 스팸 · 지우기는 막힌다 (409 MAIL_015 · failedIds 경로). * 붙잡힘 판정(보낼 시각이 지금 뒤인 pending 제출 · fail closed)은 jmap-mail.provider.spec.ts 가 본다 */ const HELD_MESSAGE = 'eheldbyengine'; /** 9단계 — 외부 이미지가 든 메일 (대역 엔진의 GET /api/messages/{id}) · 그 바깥 주소 */ const IMAGE_MESSAGE = 'imagemessage'; const PUBLIC_IMAGE_URL = 'https://images.e2e.example/logo.png'; const INTERNAL_IMAGE_URL = 'https://intranet.e2e.example/secret.png'; /** 대역 이름 풀기 — 공인 주소 · 사설 주소 (그 밖의 이름은 풀지 못한다) */ const IMAGE_HOSTS = new Map([ ['images.e2e.example', [{ address: '93.184.216.34', family: 4 }]], ['intranet.e2e.example', [{ address: '10.0.0.7', family: 4 }]], ]); /** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */ 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; open: () => void } { let open!: () => void; const opened = new Promise((resolve) => { open = resolve; }); return { opened, open }; } /** 조건이 맞거나 기한이 지날 때까지 기다린다 — 맞았는지는 뒤의 단언이 본다 */ async function waitUntil( condition: () => boolean, deadlineMs: number, ): Promise { const deadline = Date.now() + deadlineMs; while (!condition() && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS)); } } /** 서버를 빈 포트로 연다 */ function listenOnAnyPort(server: Server): Promise { return new Promise((resolve) => server.listen(0, () => resolve())); } /** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */ interface EventStreamClient { readonly response: IncomingMessage; /** 지금까지 받은 글자 */ received(): string; /** 클라이언트가 끊는다 */ close(): void; } /** * 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다. * 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다 */ function openEventStream( server: Server, cookie: string, ): Promise { 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 { 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, DIRECTORY_ACCOUNT]), directoryAccount: DIRECTORY_ACCOUNT, }; /** 목록 응답의 data — 계약의 { items, pageInfo } */ interface Page { items: T[]; pageInfo: { hasNext: boolean; nextCursor: string | null; total: number | null; }; } describe('응답 계약 (e2e)', () => { let app: NestExpressApplication; let server: App; let jwt: JwtService; let authSettings: AuthSettings; let dataSource: DataSource; /** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) — 확인이 엔진에 도착한 때 는다 */ const engineChecks = new Map(); /** * 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다. * 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다). */ let slowEngineGate: Promise | null = null; /** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */ let slowMailboxGate: Promise | 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(); /** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */ 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 }[] = []; /** 7단계 — 엔진에서 우리 규칙이 꺼진 직원(부재중 · 필터가 동작하지 않음 — 저장하면 다시 켜진다) · 받은 "이미 받은 메일에 적용" */ const inactiveScripts = new Set(); const filterRuns: { user: string; request: MailFilterRunRequest }[] = []; /** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */ let slowFilterRunGate: Promise | null = null; const singleUpdates: { user: string; id: string; update: MessageUpdate; }[] = []; /** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */ const mailBlocks = new Map(); const scheduledMessages = new Map(); const canceledSends: { user: string; id: string }[] = []; /** 대역 엔진이 받은 한 통 삭제 (DELETE /api/messages/{id}) */ const deletedMessages: { user: string; id: string }[] = []; const imageRequests: MailImageRequest[] = []; const fakeImageResolver = { resolve: (hostname: string): Promise => { const addresses = IMAGE_HOSTS.get(hostname); return addresses ? Promise.resolve(addresses) : Promise.reject(new Error('e2e-unknown-host')); }, }; const fakeImageTransport = { get: (imageRequest: MailImageRequest): Promise => { imageRequests.push(imageRequest); const data = pngBytes(48, 16); return Promise.resolve({ status: 200, location: null, contentLength: data.length, body: (async function* () { yield await Promise.resolve(new Uint8Array(data)); })(), close: () => undefined, }); }, }; /** 5단계 — 대역 엔진에 열린 실시간 알림 연결 (직원마다 하나여야 한다) */ const liveWatches: { user: string; listener: MailWatchListener; closed: boolean; }[] = []; /** * 8단계 — 대역 엔진의 주소록(직원마다 하나) — 판(state)이 같을 때만 쓰고 쓸 때마다 판이 오른다 (ifInState). * 카드 모양(JSContact) · 엔진의 거절은 jmap-contacts.provider.spec.ts 가 본다 */ const addressBooks = new Map< string, { state: number; contacts: Map; groups: Map; } >(); let addressBookSequence = 0; const addressBookOf = (user: string) => { let book = addressBooks.get(user); if (!book) { book = { state: 1, contacts: new Map(), groups: new Map() }; addressBooks.set(user, book); } return book; }; const fakeContactsProvider = { getAddressBookVersion: (user: string) => Promise.resolve(`v${addressBookOf(user).state}`), readAddressBook: (user: string): Promise => { const book = addressBookOf(user); return Promise.resolve({ version: `v${book.state}`, contacts: [...book.contacts.values()], groups: [...book.groups.values()], }); }, writeAddressBook: ( user: string, version: string, changes: AddressBookChanges, ): Promise => { const book = addressBookOf(user); if (version !== `v${book.state}`) { return Promise.resolve({ kind: 'conflict' }); } const createdIds = new Map(); const cards = new Map(); const removed = new Map(); for (const id of changes.destroy) { const card = book.contacts.get(id) ?? book.groups.get(id); if (card) { const source = JSON.stringify(card); removed.set(id, { source, bytes: Buffer.byteLength(source) }); } } for (const card of changes.create) { addressBookSequence += 1; const id = `card${addressBookSequence}`; createdIds.set(card.key, id); if (card.kind === 'contact') { const stored = { id, uid: card.uid, ...card.fields }; book.contacts.set(id, stored); cards.set(id, { kind: 'contact', card: stored }); } else if (card.kind === 'group') { const stored = { id, uid: card.uid, name: card.name, memberUids: [...card.memberUids], }; book.groups.set(id, stored); cards.set(id, { kind: 'group', card: stored }); } else if (card.card.kind === 'contact') { const stored = { ...card.card.card, id, uid: card.uid }; book.contacts.set(id, stored); cards.set(id, { kind: 'contact', card: stored }); } else { const stored = { ...card.card.card, id, uid: card.uid, memberUids: [...(card.memberUids ?? [])], }; book.groups.set(id, stored); cards.set(id, { kind: 'group', card: stored }); } } for (const update of changes.update) { if (update.kind === 'contact') { const current = book.contacts.get(update.id); if (current) { const stored = { ...current, ...Object.fromEntries( Object.entries(update.fields).filter( ([, value]) => value !== undefined, ), ), }; book.contacts.set(update.id, stored); cards.set(update.id, { kind: 'contact', card: stored }); } } else { const current = book.groups.get(update.id); if (current) { book.groups.set(update.id, { ...current, name: update.name ?? current.name, memberUids: update.memberUids ? [...update.memberUids] : current.memberUids, }); } } } for (const id of changes.destroy) { book.contacts.delete(id); book.groups.delete(id); } book.state += 1; return Promise.resolve({ kind: 'written', version: `v${book.state}`, createdIds, cards, removed, }); }, }; /** 8단계 — 대역 엔진의 계정 목록. null 이면 읽지 못한다(사내 목록 계정이 없음 — 받는 사람 추천은 로그인 기록으로 대신한다) */ let directoryAccounts: DirectoryAccount[] | null = null; const fakeDirectoryProvider = { listAccounts: () => directoryAccounts ? Promise.resolve(directoryAccounts) : Promise.reject( new BusinessException('MAIL_001', { context: { reason: 'e2e-directory-account-missing' }, }), ), }; /** 이 앱의 서명 키로 토큰을 만든다 (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 => { 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 }); // 9단계 — 엔진이 붙잡았으면 보낼 시각과 종류 (실제 계산은 jmap/pending-send.ts) const hold = message.hold; const pendingSend: PendingSend | null = hold ? { kind: hold.kind, sendAt: hold.kind === 'scheduled' ? hold.until : new Date(Date.now() + hold.seconds * 1_000).toISOString(), } : null; return Promise.resolve({ id: `sent${sentMessages.length}`, rejectedRecipients, pendingSend, }); }, // 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts) saveDraft: ( user: string, content: DraftContent, replaceId?: string, ): Promise => { 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 => { 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, isCanceledSend: false, }); }, 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 => { 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 => { bulkUpdates.push({ user, ids, update }); if (update.mailboxId === MISSING_MAILBOX) { return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-mailbox-not-found' }, }), ); } // 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 failedIds 로 (9단계) const moving = update.mailboxId !== undefined || update.isJunk !== undefined; const fails = (id: string) => id === GONE_MESSAGE || (moving && id === HELD_MESSAGE); return Promise.resolve({ updatedIds: ids.filter((id) => !fails(id)), failedIds: ids.filter(fails), }); }, deleteFromTrash: ( user: string, ids: readonly string[], ): Promise => { 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 => { passwordChanges.push({ email, newPassword }); if (currentPassword !== CORRECT_PASSWORD) { return Promise.resolve('current-password-rejected'); } const outcomes = new Map([ [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 => { 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 => { 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 => { mailboxCalls.push({ user, action: 'delete', args: [id] }); return Promise.resolve({ deleted: true, processed: 4, movedToTrash: 3, remaining: 0, }); }, markMailboxRead: ( user: string, id: string, ): Promise => { mailboxCalls.push({ user, action: 'mark-read', args: [id] }); return Promise.resolve({ processed: 5, remaining: 0 }); }, emptyMailbox: async ( user: string, id: string, ): Promise => { mailboxCalls.push({ user, action: 'empty', args: [id] }); if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate; return { processed: 2, remaining: 0 }; }, getVacation: (user: string): Promise => Promise.resolve({ ...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION), isActive: !inactiveScripts.has(user), }), // 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts) setVacation: ( user: string, vacation: MailVacation, ): Promise => { vacationSaves.push({ user, vacation }); mailScripts.set(user, { rules: mailScripts.get(user)?.rules ?? [], vacation, }); inactiveScripts.delete(user); return Promise.resolve({ ...vacation, isActive: true }); }, listMailboxes: (): Promise => Promise.resolve(E2E_MAILBOXES), // 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다 getMailFilters: (user: string): Promise => { const rules = mailScripts.get(user)?.rules ?? []; return Promise.resolve({ rules, version: fakeFilterVersion(rules), isActive: true, }); }, setMailFilters: ( user: string, update: MailFilterUpdate, ): Promise => { 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, }); }, // 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다 runMailFilter: async ( user: string, request: MailFilterRunRequest, ): Promise => { filterRuns.push({ user, request }); if (request.cursor === OTHER_RULE_CURSOR) { throw new BusinessException('VAL_001', { context: { reason: 'e2e-filter-run-other-rule' }, }); } if (slowFilterRunGate) await slowFilterRunGate; return request.cursor === null ? { scanned: 500, matched: 3, remaining: 120, cursor: 'e2enextcursor', rounds: 1, } : { scanned: 120, matched: 1, remaining: 0, cursor: null, rounds: 1, }; }, openMessageSource: ( _user: string, id: string, ): Promise => { 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 => { singleUpdates.push({ user, id, update }); if ( id === HELD_MESSAGE && (update.mailboxId !== undefined || update.isJunk !== undefined) ) { return Promise.reject( new BusinessException('MAIL_015', { context: { reason: 'e2e-held-send-locked' }, }), ); } 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, pendingSend: null, }); }, // 9단계 — 메일 읽기 (외부 이미지 확인용 한 통만). 정화 · 그림 판정은 html/*.spec.ts · mail.service.spec.ts 가 본다 getMessage: ( user: string, id: string, ): Promise => { if (id !== IMAGE_MESSAGE) { return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-message-not-found' }, }), ); } return Promise.resolve({ id, threadId: id, mailboxIds: ['a'], from: { name: '바깥 보낸 사람', email: 'news@letters.e2e.example' }, to: [{ name: null, email: user }], subject: '그림이 든 메일', preview: '', receivedAt: '2026-10-29T01:00:00.000Z', isRead: true, isFlagged: false, hasAttachment: false, size: 2_000, pendingSend: null, cc: [], bcc: [], replyTo: [], sentAt: null, messageId: 'image@e2e', references: [], rawHtmlBody: `

안녕하세요

로고`, isRawHtmlBodyTruncated: false, textBody: '안녕하세요', isTextBodyTruncated: false, attachments: [], inlineParts: [], isInJunkMailbox: false, }); }, // 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다) getMailBlocks: (user: string): Promise => Promise.resolve({ senders: [...(mailBlocks.get(user) ?? [])], isActive: true, }), addMailBlock: ( user: string, sender: BlockedSender, ): Promise => { const current = mailBlocks.get(user) ?? []; const existing = current.find( ({ address }) => address === sender.address, ); if (existing) { return Promise.resolve({ sender: existing, created: false }); } mailBlocks.set(user, [sender, ...current]); return Promise.resolve({ sender, created: true }); }, removeMailBlock: (user: string, address: string): Promise => { const current = mailBlocks.get(user) ?? []; if (!current.some((entry) => entry.address === address)) { return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-mail-block-not-found' }, }), ); } mailBlocks.set( user, current.filter((entry) => entry.address !== address), ); return Promise.resolve(); }, // 한 통 삭제 — 붙잡힌 메일은 409 MAIL_015 (9단계), 없으면 RES_001 (조건부 영구 삭제는 jmap-mail.provider.spec.ts) deleteMessage: (user: string, id: string): Promise => { if (id === HELD_MESSAGE) { return Promise.reject( new BusinessException('MAIL_015', { context: { reason: 'e2e-held-send-locked' }, }), ); } if (id === GONE_MESSAGE) { return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-message-not-found' }, }), ); } deletedMessages.push({ user, id }); return Promise.resolve(); }, // 9단계 — 붙잡은 발송 (엔진의 기다리는 제출 · 되돌리기는 jmap-mail.provider.spec.ts 가 본다) listScheduledMessages: (user: string): Promise => Promise.resolve(scheduledMessages.get(user) ?? []), cancelPendingSend: (user: string, id: string): Promise => { if (id === ALREADY_RELEASED_MESSAGE) { return Promise.reject( new BusinessException('MAIL_014', { context: { reason: 'e2e-not-a-pending-send' }, }), ); } if (id === GONE_MESSAGE) { return Promise.reject( new BusinessException('RES_001', { context: { reason: 'e2e-message-not-found' }, }), ); } canceledSends.push({ user, id }); return Promise.resolve(); }, }) .overrideProvider(MAIL_SETTINGS) .useValue(TEST_MAIL_SETTINGS) .overrideProvider(CONTACTS_PROVIDER) .useValue(fakeContactsProvider) .overrideProvider(DIRECTORY_PROVIDER) .useValue(fakeDirectoryProvider) // 9단계 — 바깥 이미지 받기는 실제 네트워크에 나가지 않는다 (SSRF 검사 · 다시 가기 · 크기는 images/*.spec.ts) .overrideProvider(MAIL_IMAGE_RESOLVER) .useValue(fakeImageResolver) .overrideProvider(MAIL_IMAGE_TRANSPORT) .useValue(fakeImageTransport) .compile(); // CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다) dataSource = moduleFixture.get(DataSource); await dataSource.runMigrations(); // main.ts 와 **같은 함수**로 전역 설정을 적용한다 — 실제 응답과 같아진다 app = moduleFixture.createNestApplication({ bodyParser: false, }); // 요청 추적 ID 도 configureApp 이 맨 앞에 붙인다 (본문 파서 오류에도 traceId) configureApp(app); await app.init(); server = app.getHttpServer(); jwt = app.get(JwtService, { strict: false }); authSettings = app.get(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', 'DELETE FROM mail_settings WHERE user_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('사내 목록 계정(8단계)도 로그인할 수 없다 — 401 AUTH_004 이고 메일 엔진에 묻지 않는다', async () => { const res = await request(server) .post('/api/auth/login') .set('X-Forwarded-For', '203.0.113.21') .send({ email: DIRECTORY_ACCOUNT, password: CORRECT_PASSWORD }) .expect(401); expect(body(res).error?.code).toBe('AUTH_004'); expect(engineChecks.has(DIRECTORY_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'], ['post', '/api/mail-filter-runs'], ['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: '

쓰는 중

', }).expect(201); const saved = dataOf(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: '

쓰는 중

', }); }); it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => { const first = dataOf( await save('post', '/api/drafts', {}).expect(201), ); const second = dataOf( await save('put', `/api/drafts/${first.id}`, { to: [DOYUN], subject: '고친 제목', bodyHtml: '

굵게

', }).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(res)).toEqual({ id: second.id, to: [DOYUN], cc: [], bcc: [], subject: '고친 제목', body: '본문', bodyHtml: '

굵게

', 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( 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( 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(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>( 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(''), 'logo.png', 'image/png', ], // 이름과 MIME 이 다르다 [pngBytes(10, 10), 'logo.png', 'image/gif'], [pngBytes(10, 10), 'logo.webp', 'image/webp'], [Buffer.from(''), '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) => ``; 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: `

김아라

`, body: '김아라 · 아라시스템', isForReply: false, }).expect(200); expect(dataOf>(first)).toMatchObject({ bodyHtml: `

김아라

`, 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: ``, 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(current)).toEqual(dataOf(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: `

${'가'.repeat(10_000)}

` }, ]) { 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 = `

본문

${ids.map((id) => ``).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) => ``; await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect( 200, ); // 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다 const [first, second] = await Promise.all([ saveSignature(user, { bodyHtml: `

탭 1

${img(a)}` }), saveSignature(user, { bodyHtml: `

탭 2

${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: ``, }).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 = `

본문

로고`; 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( `

본문

로고`, ); expect(sent.message.inlineImages).toEqual([ { cid, type: 'image/png', name: 'signature-1.png', data }, ]); const saved = dataOf( 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( `

본문

로고`, ); }); }); 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) => request(server) .get('/api/recipient-suggestions') .query(params) .set('Cookie', cookieOf(ME)); interface SuggestionPage { items: { name: string | null; email: string; source: string; company: string | null; emailLabel: string | null; }[]; 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(res); const emails = items.map((item) => item.email); expect(items).toContainEqual({ name: '가나다', email: COLLEAGUE_A, source: 'directory', company: null, emailLabel: null, }); // 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만 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', company: null, emailLabel: null, }); 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( 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( 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[] = [ { 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( 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) => 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(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[] = [ {}, { 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('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — isActive 를 뺀 그대로 다시 저장해도 200, isActive 를 넣으면 400', 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, isActive: true }, }); const { isActive, ...stored } = dataOf(res); expect(isActive).toBe(true); const restored = await put(stored).expect(200); expect(dataOf(restored)).toEqual({ ...NEVER_CONFIGURED_VACATION, isActive: true, }); // 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState const before = vacationSaves.length; const rejected = await put(dataOf(res)).expect(400); expect(body(rejected).error?.code).toBe('VAL_001'); expect(vacationSaves).toHaveLength(before); }); it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => { const res = await put(vacation).expect(200); expect(dataOf(res)).toEqual({ ...vacation, isActive: true }); 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(read)).toEqual({ ...vacation, isActive: true }); }); 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: '

휴가

' }, { ...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(res)).toEqual({ ...full, isActive: true }); }); 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(res)).toEqual({ ...longOff, isActive: true }); 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(res)).toEqual({ ...saved, isActive: true }); 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, ): Promise { 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( 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: [], // 9단계 — 처음 설정은 보내기 취소 10초: 엔진이 붙잡았다가 보낸다 pendingSend: { kind: 'undo', sendAt: expect.any(String) as string }, }, }); }); it('일부만 받지 않았으면 보낸 것이다 — 201 에 받지 않은 사람(요청에 쓴 주소 · 까닭), 이어 쓴 저장본은 지운다', async () => { const draftId = await savedDraft(); const res = await send({ to: [DOYUN], cc: [address(NOBODY)], bcc: [address(BUSY)], draftId, }).expect(201); expect(dataOf(res)).toEqual({ id: expect.stringMatching(/^sent\d+$/) as string, rejectedRecipients: [ { email: NOBODY, reason: 'not-found' }, { email: BUSY, reason: 'temporary' }, ], pendingSend: { kind: 'undo', sendAt: expect.any(String) as string }, }); 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(read)).toEqual({ ...vacation, isActive: true }); 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 }]); }); }); }); describe('7단계 — 이미 받은 메일에 필터 적용 · 부재중의 isActive', () => { describe('이미 받은 메일에 적용 (POST /api/mail-filter-runs)', () => { const ME = 'filter.run@e2e.arasystem.test'; const run = { match: 'any', conditions: [ { field: 'from', operator: 'contains', value: '@hanbit-partners.co.kr', }, ], actions: { moveToMailboxId: 'j', markAsRead: true, flag: false }, cursor: null, }; const post = (payload: unknown, email = ME) => request(server) .post('/api/mail-filter-runs') .set('Cookie', cookieOf(email)) .send(payload as object); it('200(201 이 아니다) — 확인 · 적용 · 남은 수와 cursor, 차례 수는 응답에 없다 · 받은 cursor 로 다시 보내면 이어 한다 (찾을 글은 앞뒤 공백을 지워 넘긴다)', async () => { const first = await post({ ...run, conditions: [ { ...run.conditions[0], value: ' @hanbit-partners.co.kr ' }, ], }).expect(200); expect(body(first)).toEqual({ success: true, data: { scanned: 500, matched: 3, remaining: 120, cursor: 'e2enextcursor', }, }); expect(filterRuns.at(-1)).toEqual({ user: ME, request: run }); const next = await post({ ...run, cursor: 'e2enextcursor' }).expect( 200, ); expect(dataOf(next)).toEqual({ scanned: 120, matched: 1, remaining: 0, cursor: null, }); expect(filterRuns.at(-1)?.request.cursor).toBe('e2enextcursor'); }); it('형식 · 칸 사이의 규칙이 어긋나면 400 VAL_001 — 엔진에 묻지 않는다 (전달 · 보낸편지함 · 임시보관함 · 할 일 없음 · cursor 빠짐 · 모르는 필드)', async () => { const before = filterRuns.length; const withActions = (overrides: object) => ({ ...run, actions: { ...run.actions, ...overrides }, }); const withoutCursor = Object.fromEntries( Object.entries(run).filter(([key]) => key !== 'cursor'), ); const payloads = [ withActions({ forwardTo: 'doyun.lee@e2e.arasystem.test' }), withActions({ moveToMailboxId: 'e' }), withActions({ moveToMailboxId: 'd' }), withActions({ moveToMailboxId: null, markAsRead: false }), { ...run, match: 'always' }, { ...run, conditions: [] }, { ...run, conditions: [{ ...run.conditions[0], value: '광고${x}' }], }, withoutCursor, { ...run, cursor: 'not base64url!' }, { ...run, cursor: 'a'.repeat(1_025) }, { ...run, version: 'v' }, { ...run, id: 'rule-1' }, ]; const answers: string[] = []; for (const payload of payloads) { const res = await post(payload); answers.push(`${res.status} ${body(res).error?.code ?? ''}`); } expect(answers).toEqual(payloads.map(() => '400 VAL_001')); expect(filterRuns).toHaveLength(before); }); it('다른 규칙의 cursor 는 어댑터가 400 VAL_001 로 막는다 · 없는 메일함(지워짐)으로 옮기는 규칙은 받는다', async () => { const res = await post({ ...run, cursor: OTHER_RULE_CURSOR }).expect( 400, ); expect(body(res).error?.code).toBe('VAL_001'); await post({ ...run, actions: { ...run.actions, moveToMailboxId: 'deletedbox' }, }).expect(200); }); it( '같은 직원의 적용이 진행 중이면 429 REQ_001 — 엔진에 묻지 않는다. 다른 직원은 된다, 끝나면 다시 된다', async () => { const gate = createGate(); slowFilterRunGate = gate.opened; try { const before = filterRuns.length; const slow = post(run).then((res) => res); await waitUntil(() => filterRuns.length > before, GATE_DEADLINE_MS); expect(filterRuns).toHaveLength(before + 1); const busy = await post(run).expect(429); expect(body(busy).error?.code).toBe('REQ_001'); expect(filterRuns).toHaveLength(before + 1); gate.open(); expect((await slow).status).toBe(200); } finally { gate.open(); slowFilterRunGate = null; } await post(run).expect(200); await post(run, 'filter.run.other@e2e.arasystem.test').expect(200); }, GATED_TEST_TIMEOUT_MS, ); it('본문의 모양 한도(배열 하나 1,000)를 넘으면 DTO 검사 전에 400 VAL_001 — 로그인 전이어도 401 이 아니다', async () => { const huge = JSON.stringify({ ...run, conditions: Array.from({ length: 80_000 }, () => ({})), }); for (const cookie of [cookieOf(ME), '']) { const res = await request(server) .post('/api/mail-filter-runs') .set('Cookie', cookie) .set('Content-Type', 'application/json') .send(huge); expect(res.status).toBe(400); expect(body(res).error?.code).toBe('VAL_001'); expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); } }); }); describe('부재중 자동 응답의 isActive (GET · PUT /api/mail-vacation)', () => { const ME = 'vacation.inactive@e2e.arasystem.test'; it('엔진에서 우리 규칙이 꺼져 있으면 isActive: false — 켜 둔 값은 그대로, 저장하면 다시 켜진다', async () => { const vacation = { isEnabled: true, fromDate: null, toDate: null, subject: '부재중입니다', textBody: '11월 7일까지 휴가입니다.', }; await request(server) .put('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .send(vacation) .expect(200); inactiveScripts.add(ME); const read = await request(server) .get('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .expect(200); expect(dataOf(read)).toEqual({ ...vacation, isActive: false }); const saved = await request(server) .put('/api/mail-vacation') .set('Cookie', cookieOf(ME)) .send(vacation) .expect(200); expect(dataOf(saved)).toEqual({ ...vacation, isActive: true }); }); }); }); /** * 8단계 — 주소록 (계약 3절). 엔진 쪽 판단(JSContact 카드 모양 · ifInState · 계정 목록 읽기)은 jmap-contacts.provider.spec.ts · * contact-card.spec.ts · jmap-directory.provider.spec.ts, 찾기 · 차례 · 한도 · 그룹 정리는 contacts.service.spec.ts 가 본다 — * 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 에러 코드 · 응답 형식 · 머리글을 본다 */ describe('8단계 — 주소록 (/api/contacts · /api/contact-groups · /api/directory-entries)', () => { const ME = 'contacts.me@e2e.arasystem.test'; interface ContactBody { id: string; name: string | null; emails: { address: string; label: string }[]; phones: { number: string; label: string }[]; company: string | null; department: string | null; title: string | null; note: string | null; groupIds: string[]; } interface GroupBody { id: string; name: string; memberCount: number; } interface DirectoryEntryBody { id: string; name: string; email: string; kind: string; } const contactInput = (overrides: Record = {}) => ({ name: '박지훈', emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], phones: [{ number: '010-3192-5540', label: 'mobile' }], company: '한빛상사', department: '구매팀', title: '과장', note: '견적 담당', groupIds: [], ...overrides, }); const asUser = (email: string) => cookieOf(email); const get = (path: string, user = ME) => request(server).get(path).set('Cookie', asUser(user)); const post = (path: string, user = ME) => request(server).post(path).set('Cookie', asUser(user)); const patch = (path: string, user = ME) => request(server).patch(path).set('Cookie', asUser(user)); const del = (path: string, user = ME) => request(server).delete(path).set('Cookie', asUser(user)); const expectCode = (res: Response, code: string) => { expect(body(res).success).toBe(false); expect(body(res).error?.code).toBe(code); expect(body(res).error?.traceId).toBe(res.headers['x-request-id']); }; it('로그인 없이는 401 AUTH_001', async () => { for (const [method, path] of [ ['get', '/api/contacts'], ['post', '/api/contacts'], ['get', '/api/contacts/abc'], ['patch', '/api/contacts/abc'], ['delete', '/api/contacts/abc'], ['get', '/api/contact-groups'], ['post', '/api/contact-groups'], ['patch', '/api/contact-groups/abc'], ['delete', '/api/contact-groups/abc'], ['post', '/api/contact-groups/abc/members'], ['post', '/api/contact-restorations'], ['get', '/api/directory-entries'], ['get', '/api/directory-entries/abc'], ] as const) { const res = await request(server)[method](path).expect(401); expectCode(res, 'AUTH_001'); } }); it('사내 목록을 읽지 못하면(사내 목록 계정이 없음) 503 MAIL_001', async () => { directoryAccounts = null; const res = await get('/api/directory-entries').expect(503); expectCode(res, 'MAIL_001'); }); it('사내 목록 — 관리자 · 게이트웨이 · 사내 목록 계정 · 바깥 주소는 빠지고 이름 차례, kind · q(초성) · 커서, Cache-Control: private, no-store', async () => { directoryAccounts = [ { id: 'a', kind: 'person', displayName: 'System administrator', email: ADMIN_ACCOUNT, }, { id: 'g', kind: 'person', displayName: '메일 게이트웨이 (백엔드 대리 접근 전용) [setup.mjs]', email: 'mail-gateway@e2e.arasystem.test', }, { id: 'm', kind: 'person', displayName: '사내 목록 (백엔드 계정 목록 읽기 전용) [setup.mjs]', email: DIRECTORY_ACCOUNT, }, { id: 'd', kind: 'person', displayName: '이도윤', email: 'doyun.lee@e2e.arasystem.test', }, { id: 'k', kind: 'person', displayName: '김아라', email: 'ara.kim@e2e.arasystem.test', }, { id: 's', kind: 'shared', displayName: '영업팀', email: 'sales@e2e.arasystem.test', }, { id: 'x', kind: 'person', displayName: '바깥', email: 'outside@partner.e2e.example', }, ]; const res = await get('/api/directory-entries').expect(200); expect(res.headers['cache-control']).toBe('private, no-store'); expect(body(res).success).toBe(true); expect(dataOf>(res)).toEqual({ items: [ { id: 'k', name: '김아라', email: 'ara.kim@e2e.arasystem.test', kind: 'person', }, { id: 's', name: '영업팀', email: 'sales@e2e.arasystem.test', kind: 'shared', }, { id: 'd', name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', kind: 'person', }, ], pageInfo: { hasNext: false, nextCursor: null, total: 3 }, }); const idsOf = async (query: Record) => dataOf>( await get('/api/directory-entries').query(query).expect(200), ).items.map((entry) => entry.id); expect(await idsOf({ kind: 'shared' })).toEqual(['s']); expect(await idsOf({ q: 'ㄷㅇ' })).toEqual(['d']); expect(await idsOf({ q: 'ARA.KIM' })).toEqual(['k']); const first = dataOf>( await get('/api/directory-entries').query({ limit: 1 }).expect(200), ); expect(first.pageInfo).toMatchObject({ hasNext: true, total: 3 }); const cursor = first.pageInfo.nextCursor ?? ''; expect(await idsOf({ limit: 1, cursor })).toEqual(['s']); for (const query of [ { cursor, kind: 'person' }, { cursor: 'not-a-cursor' }, { kind: 'group' }, { q: 'x'.repeat(255) }, { limit: 101 }, ]) { expectCode( await get('/api/directory-entries').query(query).expect(400), 'VAL_001', ); } // 메일 주소 하나(254자)는 찾을 수 있다 expect(await idsOf({ q: 'x'.repeat(254) })).toEqual([]); // 한 항목 — 기억한 목록에서, 빼는 계정 · 없는 id · 형식이 틀린 id 는 404 RES_001 const one = await get('/api/directory-entries/d').expect(200); expect(one.headers['cache-control']).toBe('private, no-store'); expect(dataOf(one)).toEqual({ id: 'd', name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', kind: 'person', }); for (const id of ['a', 'm', 'nope', 'bad.id']) { expectCode( await get(`/api/directory-entries/${id}`).expect(404), 'RES_001', ); } }); it('사내 목록 excludeGroupId — 내 그룹 구성원이 가진 주소(대소문자 무시)를 뺀다, 없는 그룹은 404 RES_001', async () => { const PICKER = 'contacts.picker@e2e.arasystem.test'; const group = dataOf( await post('/api/contact-groups', PICKER) .send({ name: '팀' }) .expect(201), ); const member = dataOf( await post('/api/contacts', PICKER) .send( contactInput({ emails: [ { address: 'Doyun.Lee@e2e.arasystem.test', label: 'work' }, ], groupIds: [group.id], }), ) .expect(201), ); expect(member.groupIds).toEqual([group.id]); const ids = dataOf>( await get('/api/directory-entries', PICKER) .query({ excludeGroupId: group.id }) .expect(200), ).items.map((entry) => entry.id); expect(ids).toEqual(['k', 's']); expectCode( await get('/api/directory-entries', PICKER) .query({ excludeGroupId: 'nogroup' }) .expect(404), 'RES_001', ); }); it('연락처 추가 201 · 하나 · 목록(초성 · 전화번호 숫자 · 대소문자) · 고치기 · 지우기 — 표준 래퍼, 지운 뒤에는 404 RES_001', async () => { const created = await post('/api/contacts') .send( contactInput({ name: ' 박지훈 ', note: '견적 담당\n월요일 오전' }), ) .expect(201); expect(body(created).success).toBe(true); const contact = dataOf(created); expect(contact).toEqual({ id: contact.id, name: '박지훈', emails: [{ address: 'jihoon.park@hanbit.e2e.example', label: 'work' }], phones: [{ number: '010-3192-5540', label: 'mobile' }], company: '한빛상사', department: '구매팀', title: '과장', note: '견적 담당\n월요일 오전', groupIds: [], }); const one = await get(`/api/contacts/${contact.id}`).expect(200); expect(one.headers['cache-control']).toBe('private, no-store'); expect(dataOf(one)).toEqual(contact); for (const q of ['ㅂㅈㅎ', '31925540', 'HANBIT', '구매']) { const page = dataOf>( await get('/api/contacts').query({ q }).expect(200), ); expect(page.items.map((item) => item.id)).toEqual([contact.id]); expect(page.pageInfo).toEqual({ hasNext: false, nextCursor: null, total: 1, }); } // 고치기는 바꾼 칸만 — 보내지 않은 칸은 그대로 const updated = await patch(`/api/contacts/${contact.id}`) .send({ title: '부장', note: null }) .expect(200); expect(dataOf(updated)).toEqual({ ...contact, title: '부장', note: null, }); const removed = await del(`/api/contacts/${contact.id}`).expect(200); const deleted = dataOf<{ contact: ContactBody; restoreToken: string }>( removed, ); expect(deleted.contact).toMatchObject({ id: contact.id, title: '부장' }); expect(deleted.restoreToken).toMatch(/^[A-Za-z0-9_-]{22}$/); expectCode( await get(`/api/contacts/${contact.id}`).expect(404), 'RES_001', ); expectCode( await del(`/api/contacts/${contact.id}`).expect(404), 'RES_001', ); // 되돌리기 — 새 id 로 그대로, 표는 한 번만 · 다른 직원은 쓸 수 없다 expectCode( await post( '/api/contact-restorations', 'someone.else@e2e.arasystem.test', ) .send({ token: deleted.restoreToken }) .expect(404), 'RES_001', ); const restored = await post('/api/contact-restorations') .send({ token: deleted.restoreToken }) .expect(201); const back = dataOf<{ kind: string; contact: ContactBody }>(restored); expect(back.kind).toBe('contact'); expect(back.contact).toEqual({ ...deleted.contact, id: back.contact.id }); expect(back.contact.id).not.toBe(contact.id); expectCode( await post('/api/contact-restorations') .send({ token: deleted.restoreToken }) .expect(404), 'RES_001', ); for (const bad of [ {}, { token: 'short' }, { token: deleted.restoreToken, extra: 1 }, ]) { expectCode( await post('/api/contact-restorations').send(bad).expect(400), 'VAL_001', ); } // 다음 테스트를 위해 비운다 await del(`/api/contacts/${back.contact.id}`).expect(200); }); it('형식 오류는 400 VAL_001 — 이름도 이메일도 없음 · 같은 주소 두 번 · 전화번호 형식 · 칸이 빠짐 · 모르는 칸 / 형식이 틀린 id 는 404 RES_001', async () => { const missingNote: Record = contactInput(); delete missingNote.note; for (const bad of [ contactInput({ name: null, emails: [] }), contactInput({ name: ' ', emails: [] }), contactInput({ emails: [ { address: 'same@x.e2e.example', label: 'work' }, { address: 'SAME@X.e2e.example', label: 'other' }, ], }), contactInput({ phones: [{ number: '내선 12', label: 'work' }] }), contactInput({ emails: [{ address: 'not-an-address', label: 'work' }], }), missingNote, contactInput({ birthday: '1990-05-01' }), ]) { expectCode( await post('/api/contacts').send(bad).expect(400), 'VAL_001', ); } expectCode(await get('/api/contacts/bad.id').expect(404), 'RES_001'); expectCode( await patch('/api/contacts/bad.id').send({ title: 'x' }).expect(404), 'RES_001', ); // 고치기 — 빈 본문 · 모르는 칸 · 보낸 칸의 형식 오류 · 합친 결과에 이름도 이메일도 없음은 400, PUT 은 없다 const VALIDATOR = 'contacts.validate@e2e.arasystem.test'; const target = dataOf( await post('/api/contacts', VALIDATOR) .send(contactInput({ emails: [] })) .expect(201), ); for (const bad of [ {}, { birthday: '1990-05-01' }, { phones: [{ number: '내선', label: 'work' }] }, { emails: null }, { name: null }, ]) { expectCode( await patch(`/api/contacts/${target.id}`, VALIDATOR) .send(bad) .expect(400), 'VAL_001', ); } expectCode( await request(server) .put(`/api/contacts/${target.id}`) .set('Cookie', asUser(VALIDATOR)) .send(contactInput()) .expect(404), 'RES_001', ); }); it('그룹 — 만들기 201 · 같은 이름(대소문자 무시) 409 CONTACT_001 · 이름 바꾸기 · 구성원 넣기(내 연락처 · 사내 목록) · 그룹의 구성원 목록 · 지우기와 [되돌리기]', async () => { const group = dataOf( await post('/api/contact-groups') .send({ name: 'Partners' }) .expect(201), ); expect(group).toEqual({ id: group.id, name: 'Partners', memberCount: 0 }); expectCode( await post('/api/contact-groups') .send({ name: ' partners ' }) .expect(409), 'CONTACT_001', ); expectCode( await post('/api/contact-groups').send({ name: '' }).expect(400), 'VAL_001', ); const renamed = await patch(`/api/contact-groups/${group.id}`) .send({ name: '거래처' }) .expect(200); expect(dataOf(renamed)).toEqual({ id: group.id, name: '거래처', memberCount: 0, }); const contact = dataOf( await post('/api/contacts').send(contactInput()).expect(201), ); const added = await post(`/api/contact-groups/${group.id}/members`) .send({ contactIds: [contact.id], directoryIds: ['d'] }) .expect(200); expect(dataOf(added)).toEqual({ group: { id: group.id, name: '거래처', memberCount: 2 }, addedCount: 2, createdCount: 1, }); // 다시 넣으면 세지 않는다 expect( dataOf<{ addedCount: number }>( await post(`/api/contact-groups/${group.id}/members`) .send({ contactIds: [contact.id], directoryIds: ['d'] }) .expect(200), ).addedCount, ).toBe(0); const members = dataOf>( await get('/api/contacts') .query({ groupId: group.id, limit: 100 }) .expect(200), ); expect(members.items.map((item) => item.emails[0]?.address)).toEqual([ 'jihoon.park@hanbit.e2e.example', 'doyun.lee@e2e.arasystem.test', ]); expect( members.items.every((item) => item.groupIds.includes(group.id)), ).toBe(true); const groups = dataOf>( await get('/api/contact-groups').expect(200), ); expect(groups.items).toContainEqual({ id: group.id, name: '거래처', memberCount: 2, }); expectCode( await post(`/api/contact-groups/${group.id}/members`) .send({}) .expect(400), 'VAL_001', ); for (const bad of [{ contactIds: ['nope'] }, { directoryIds: ['a'] }]) { expectCode( await post(`/api/contact-groups/${group.id}/members`) .send(bad) .expect(404), 'RES_001', ); } expectCode( await patch('/api/contact-groups/nope').send({ name: 'x' }).expect(404), 'RES_001', ); const deleted = dataOf<{ id: string; name: string; memberIds: string[]; restoreToken: string; }>(await del(`/api/contact-groups/${group.id}`).expect(200)); expect(deleted).toEqual({ id: group.id, name: '거래처', memberIds: members.items.map((item) => item.id), restoreToken: expect.stringMatching(/^[A-Za-z0-9_-]{22}$/) as string, }); // 연락처는 그대로 — 그 그룹은 이제 없다 expect( dataOf>( await get('/api/contacts').query({ limit: 100 }).expect(200), ).items.map((item) => item.groupIds), ).toEqual([[], []]); expectCode( await get('/api/contacts').query({ groupId: group.id }).expect(404), 'RES_001', ); // 만들기는 { name } 만 — 지운 그룹은 되돌리기로 expectCode( await post('/api/contact-groups') .send({ name: deleted.name, memberIds: deleted.memberIds }) .expect(400), 'VAL_001', ); const restored = dataOf<{ kind: string; group: GroupBody }>( await post('/api/contact-restorations') .send({ token: deleted.restoreToken }) .expect(201), ); expect(restored).toMatchObject({ kind: 'group', group: { name: '거래처', memberCount: 2 }, }); expect(restored.group.id).not.toBe(group.id); // excludeGroupId — 그 그룹의 구성원을 빼고 (groupId 와 함께 쓰면 400) expect( dataOf>( await get('/api/contacts') .query({ excludeGroupId: restored.group.id }) .expect(200), ).items, ).toEqual([]); expectCode( await get('/api/contacts') .query({ groupId: restored.group.id, excludeGroupId: restored.group.id, }) .expect(400), 'VAL_001', ); }); it(`한도 — 연락처 ${MAX_CONTACTS}개면 409 CONTACT_002 (아무것도 만들지 않는다)`, async () => { const FULL = 'contacts.full@e2e.arasystem.test'; const book = addressBookOf(FULL); for (let index = 0; index < MAX_CONTACTS; index += 1) { book.contacts.set(`f${index}`, { id: `f${index}`, uid: `urn:uuid:f${index}`, name: `연락처 ${index}`, emails: [], phones: [], company: null, department: null, title: null, note: null, }); } expectCode( await post('/api/contacts', FULL).send(contactInput()).expect(409), 'CONTACT_002', ); expect(book.contacts.size).toBe(MAX_CONTACTS); }); it('커서는 받은 그 q · groupId 로만 — 다르면 400 VAL_001, 없는 groupId 는 404 RES_001, limit 범위 밖은 400', async () => { const LISTER = 'contacts.lister@e2e.arasystem.test'; for (const name of ['가나다', '나비']) { await post('/api/contacts', LISTER) .send(contactInput({ name, emails: [] })) .expect(201); } const first = dataOf>( await get('/api/contacts', LISTER).query({ limit: 1 }).expect(200), ); expect(first.items.map((item) => item.name)).toEqual(['가나다']); expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 }); const cursor = first.pageInfo.nextCursor ?? ''; expect( dataOf>( await get('/api/contacts', LISTER) .query({ limit: 1, cursor }) .expect(200), ).items.map((item) => item.name), ).toEqual(['나비']); expectCode( await get('/api/contacts', LISTER) .query({ cursor, q: '나' }) .expect(400), 'VAL_001', ); expectCode( await get('/api/contacts', LISTER).query({ limit: 0 }).expect(400), 'VAL_001', ); expectCode( await get('/api/contacts', LISTER) .query({ groupId: 'nogroup' }) .expect(404), 'RES_001', ); }); it('받는 사람 추천 — 주소록(회사 · 종류) → 사내(엔진의 사내 목록) → 최근, 연락처를 바꾸면 바로 보인다', async () => { const SUGGESTER = 'contacts.suggest@e2e.arasystem.test'; const now = new Date().toISOString(); correspondents.set(SUGGESTER, [ { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', at: now }, { name: '협력사', email: 'vendor@partner.e2e.example', at: now }, ]); const suggestions = async () => dataOf< Page<{ name: string | null; email: string; source: string; company: string | null; emailLabel: string | null; }> >( await request(server) .get('/api/recipient-suggestions') .query({ limit: 100 }) .set('Cookie', cookieOf(SUGGESTER)) .expect(200), ).items; expect(await suggestions()).toEqual([ // 사내 — 주고받은 사람이 먼저, 나머지는 이름 차례 { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test', source: 'directory', company: null, emailLabel: null, }, { name: '김아라', email: 'ara.kim@e2e.arasystem.test', source: 'directory', company: null, emailLabel: null, }, { name: '영업팀', email: 'sales@e2e.arasystem.test', source: 'directory', company: null, emailLabel: null, }, { name: '협력사', email: 'vendor@partner.e2e.example', source: 'recent', company: null, emailLabel: null, }, ]); await post('/api/contacts', SUGGESTER) .send( contactInput({ emails: [ { address: 'jihoon.park@hanbit.e2e.example', label: 'work' }, { address: 'jihoon@home.e2e.example', label: 'personal' }, ], }), ) .expect(201); await post('/api/contacts', SUGGESTER) .send( contactInput({ name: '이도윤(거래처)', company: null, emails: [ { address: 'Doyun.Lee@e2e.arasystem.test', label: 'other' }, ], }), ) .expect(201); const after = await suggestions(); expect(after.slice(0, 3)).toEqual([ { name: '박지훈', email: 'jihoon.park@hanbit.e2e.example', source: 'contact', company: '한빛상사', emailLabel: 'work', }, { name: '박지훈', email: 'jihoon@home.e2e.example', source: 'contact', company: '한빛상사', emailLabel: 'personal', }, { name: '이도윤(거래처)', email: 'Doyun.Lee@e2e.arasystem.test', source: 'contact', company: null, emailLabel: 'other', }, ]); // 주소록에 있는 주소는 사내 묶음에서 빠진다 expect(after.map((item) => item.source)).toEqual([ 'contact', 'contact', 'contact', 'directory', 'directory', 'recent', ]); }); }); /** * 9단계 — 보내기 취소 · 예약 발송 · 외부 이미지 · 보낸 사람 차단 · 보내기와 읽기 설정 (계약 2절). * 엔진 쪽(붙잡기 · 취소 · Sieve · 바깥 받기의 SSRF 검사)은 jmap/*.spec.ts · images/*.spec.ts 가 본다 — * 여기서는 HTTP 형식 · 요청 검사 · 설정(DB 의 mail_settings)이 발송과 읽기에 닿는지를 본다 */ describe('9단계 — 보내기 취소 · 예약 · 외부 이미지 · 차단 · 설정', () => { const patchSettings = (email: string, payload: object) => request(server) .patch('/api/mail-settings') .set('Cookie', cookieOf(email)) .send(payload); describe('보내기 · 읽기 설정 (/api/mail-settings)', () => { const ME = 'settings.me@e2e.arasystem.test'; it('로그인하지 않았으면 401 AUTH_001', async () => { for (const res of [ await request(server).get('/api/mail-settings'), await request(server) .patch('/api/mail-settings') .send({ undoSendSeconds: 5 }), ]) { expect(res.status).toBe(401); expect(body(res).error?.code).toBe('AUTH_001'); } }); it('처음에는 처음 값 — 보내기 취소 10초 · 외부 이미지 늘 묻기 (DB 에 줄이 없어도)', async () => { const res = await request(server) .get('/api/mail-settings') .set('Cookie', cookieOf(ME)) .expect(200); expect(body(res)).toEqual({ success: true, data: { undoSendSeconds: 10, externalImages: 'ask' }, }); }); it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => { const first = await patchSettings(ME, { externalImages: 'always', }).expect(200); expect(dataOf(first)).toEqual({ undoSendSeconds: 10, externalImages: 'always', }); const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect( 200, ); expect(dataOf(second)).toEqual({ undoSendSeconds: 30, externalImages: 'always', }); const read = await request(server) .get('/api/mail-settings') .set('Cookie', cookieOf(ME)) .expect(200); expect(dataOf(read)).toEqual({ undoSendSeconds: 30, externalImages: 'always', }); const rows: { undo_send_seconds: number; external_images: string; }[] = await dataSource.query( 'SELECT undo_send_seconds, external_images FROM mail_settings WHERE user_email = $1', [ME], ); expect(rows).toEqual([ { undo_send_seconds: 30, external_images: 'always' }, ]); }); it.each<[string, object]>([ ['빈 본문(하나 이상)', {}], ['고를 수 없는 초', { undoSendSeconds: 7 }], ['글로 쓴 초', { undoSendSeconds: '10' }], ['null 초', { undoSendSeconds: null }], ['모르는 외부 이미지 값', { externalImages: 'never' }], ['null 외부 이미지', { externalImages: null }], ['모르는 칸', { theme: 'dark' }], ])('잘못된 본문은 400 VAL_001 — %s', async (_label, payload) => { const res = await patchSettings(ME, payload).expect(400); expect(body(res).error?.code).toBe('VAL_001'); }); }); describe('보내기 취소 · 예약 발송 (POST /api/messages · /api/scheduled-messages)', () => { const ME = 'schedule.me@e2e.arasystem.test'; const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }; const MINUTE_MS = 60_000; const DAY_MS = 24 * 60 * MINUTE_MS; const send = (payload: object = {}) => request(server) .post('/api/messages') .set('Cookie', cookieOf(ME)) .send({ to: [DOYUN], subject: '제목', body: '본문', ...payload }); const lastHold = () => sentMessages[sentMessages.length - 1]?.message.hold; const pendingOf = (res: Response) => dataOf<{ pendingSend: PendingSend | null }>(res).pendingSend; const summary = (id: string, sendAt: string): MessageSummary => ({ id, threadId: id, mailboxIds: ['e'], from: { name: '나', email: ME }, to: [DOYUN], subject: `예약 ${id}`, preview: '', receivedAt: '2026-10-29T01:00:00.000Z', isRead: true, isFlagged: false, hasAttachment: false, size: 100, pendingSend: { kind: 'scheduled', sendAt }, }); it('sendAt 이 있으면 그 시각(소수 초는 버린다)까지 붙잡는다 — 201 의 pendingSend 는 예약, 보내기 취소 시간을 더하지 않는다', async () => { const second = Math.floor((Date.now() + 60 * MINUTE_MS) / 1_000) * 1_000; const until = new Date(second).toISOString(); const res = await send({ sendAt: new Date(second + 750).toISOString(), }).expect(201); expect(pendingOf(res)).toEqual({ kind: 'scheduled', sendAt: until }); expect(lastHold()).toEqual({ kind: 'scheduled', until }); }); it.each<[string, () => string]>([ [ '4분 뒤 — 5분보다 이르다', () => new Date(Date.now() + 4 * MINUTE_MS).toISOString(), ], [ '7일하고 1분 뒤', () => new Date(Date.now() + 7 * DAY_MS + MINUTE_MS).toISOString(), ], ['지난 시각', () => new Date(Date.now() - MINUTE_MS).toISOString()], ['UTC 가 아님(+09:00)', () => '2030-12-01T09:00:00+09:00'], ['날짜가 아님', () => '내일 아침'], ])( '예약 시각이 %s 이면 400 VAL_001 — 엔진에 보내지 않는다', async (_label, sendAt) => { const before = sentMessages.length; const res = await send({ sendAt: sendAt() }).expect(400); expect(body(res).error?.code).toBe('VAL_001'); expect(sentMessages).toHaveLength(before); }, ); it('sendAt 이 없으면 설정의 보내기 취소 초만큼 붙잡는다 — 0초면 붙잡지 않고 pendingSend 는 null', async () => { const undo = await send().expect(201); expect(pendingOf(undo)).toEqual({ kind: 'undo', sendAt: expect.any(String) as string, }); expect(lastHold()).toEqual({ kind: 'undo', seconds: 10 }); await patchSettings(ME, { undoSendSeconds: 0 }).expect(200); const now = await send().expect(201); expect(pendingOf(now)).toBeNull(); expect(lastHold()).toBeUndefined(); await patchSettings(ME, { undoSendSeconds: 20 }).expect(200); await send().expect(201); expect(lastHold()).toEqual({ kind: 'undo', seconds: 20 }); }); it('예약한 메일 — { items, pageInfo }(total 포함), 커서로 이어 받는다 · 깨진 커서는 400 VAL_001', async () => { const at = (minutes: number) => new Date(Date.UTC(2030, 0, 1, 9, minutes)).toISOString(); scheduledMessages.set(ME, [ summary('s1', at(0)), summary('s2', at(10)), summary('s3', at(20)), ]); const first = await request(server) .get('/api/scheduled-messages?limit=2') .set('Cookie', cookieOf(ME)) .expect(200); const page = dataOf>(first); expect(page.items.map(({ id }) => id)).toEqual(['s1', 's2']); expect(page.items[0].pendingSend).toEqual({ kind: 'scheduled', sendAt: at(0), }); expect(page.pageInfo).toEqual({ hasNext: true, nextCursor: expect.any(String) as string, total: 3, }); const second = await request(server) .get( `/api/scheduled-messages?limit=2&cursor=${encodeURIComponent(page.pageInfo.nextCursor ?? '')}`, ) .set('Cookie', cookieOf(ME)) .expect(200); expect(dataOf>(second)).toEqual({ items: [expect.objectContaining({ id: 's3' }) as MessageSummary], pageInfo: { hasNext: false, nextCursor: null, total: 3 }, }); const broken = await request(server) .get('/api/scheduled-messages?cursor=bm90LWEtY3Vyc29y') .set('Cookie', cookieOf(ME)) .expect(400); expect(body(broken).error?.code).toBe('VAL_001'); await request(server).get('/api/scheduled-messages').expect(401); }); it('취소 — 200 { draftId }(같은 메일 id), 이미 보냈으면 409 MAIL_014, 없거나 id 형식이 틀리면 404 RES_001 (엔진에 묻지 않는다)', async () => { const cancel = (id: string) => request(server) .delete(`/api/scheduled-messages/${id}`) .set('Cookie', cookieOf(ME)); const ok = await cancel('eheldmsg1').expect(200); expect(body(ok)).toEqual({ success: true, data: { draftId: 'eheldmsg1' }, }); expect(canceledSends).toContainEqual({ user: ME, id: 'eheldmsg1' }); const released = await cancel(ALREADY_RELEASED_MESSAGE).expect(409); expect(body(released).error?.code).toBe('MAIL_014'); const gone = await cancel(GONE_MESSAGE).expect(404); expect(body(gone).error?.code).toBe('RES_001'); const before = canceledSends.length; const malformed = await cancel('a"b').expect(404); expect(body(malformed).error?.code).toBe('RES_001'); expect(canceledSends).toHaveLength(before); }); it('같은 발송을 두 번 취소해도 둘 다 200 { draftId } — 응답을 받지 못한 재시도 (멱등은 jmap-mail.provider.spec.ts)', async () => { const cancel = () => request(server) .delete('/api/scheduled-messages/etwicecanceled') .set('Cookie', cookieOf(ME)); for (const res of [await cancel(), await cancel()]) { expect(res.status).toBe(200); expect(body(res)).toEqual({ success: true, data: { draftId: 'etwicecanceled' }, }); } }); describe('붙잡힌 메일은 취소로만 보낸편지함을 떠난다 (409 MAIL_015)', () => { const patchOne = (payload: object) => request(server) .patch(`/api/messages/${HELD_MESSAGE}`) .set('Cookie', cookieOf(ME)) .send(payload); it.each<[string, object]>([ ['옮기기', { mailboxId: 'b' }], ['스팸 신고', { isJunk: true }], ['옮기기와 읽음 함께', { mailboxId: 'b', isRead: true }], ])( '한 통 %s 는 409 MAIL_015 — 표준 실패 형식', async (_label, payload) => { const res = await patchOne(payload).expect(409); expect(body(res)).toEqual({ success: false, error: { code: 'MAIL_015', message: '보내기를 기다리는 메일은 옮기거나 지울 수 없습니다. 먼저 보내기를 취소해 주세요.', traceId: expect.any(String) as string, }, }); }, ); it('한 통의 읽음 · 중요 표시는 된다 — 200', async () => { const res = await patchOne({ isFlagged: true, isRead: true }).expect( 200, ); expect(dataOf(res).id).toBe(HELD_MESSAGE); }); it('여러 통 옮기기는 200 — 붙잡힌 메일만 failedIds, 나머지는 옮긴다', async () => { const res = await request(server) .patch('/api/messages') .set('Cookie', cookieOf(ME)) .send({ ids: ['eaaaaab', HELD_MESSAGE], mailboxId: 'b' }) .expect(200); expect(dataOf(res)).toEqual({ updatedIds: ['eaaaaab'], failedIds: [HELD_MESSAGE], }); }); it('한 통 삭제(DELETE)는 409 MAIL_015 — 지우지 않는다', async () => { const before = deletedMessages.length; const res = await request(server) .delete(`/api/messages/${HELD_MESSAGE}`) .set('Cookie', cookieOf(ME)) .expect(409); expect(body(res).error?.code).toBe('MAIL_015'); expect(deletedMessages).toHaveLength(before); // 붙잡히지 않은 메일은 지금처럼 204 await request(server) .delete('/api/messages/eaaaaab') .set('Cookie', cookieOf(ME)) .expect(204); expect(deletedMessages).toContainEqual({ user: ME, id: 'eaaaaab' }); }); }); }); describe('외부 이미지 (GET /api/messages/{id}?images=show · GET /api/mail-images/{token})', () => { const ME = 'images.me@e2e.arasystem.test'; const OTHER = 'images.other@e2e.arasystem.test'; interface ReadDetail { htmlBody: string | null; hasBlockedImages: boolean; imagesShown: boolean; isInJunkMailbox?: unknown; } const read = (query = '', who = ME) => request(server) .get(`/api/messages/${IMAGE_MESSAGE}${query}`) .set('Cookie', cookieOf(who)); /** 본문의 중계 주소들 (sanitize-html 이 & 를 & 로 쓰지 않는 base64url 표) */ const proxiedSources = (html: string | null) => [...(html ?? '').matchAll(/src="(\/api\/mail-images\/[^"]+)"/g)].map( (match) => match[1], ); it('images 의 모르는 값 · 모르는 쿼리는 400 VAL_001', async () => { for (const query of ['?images=1', '?images=SHOW', '?show=1']) { const res = await read(query).expect(400); expect(body(res).error?.code).toBe('VAL_001'); } }); it('묻기(처음 설정)면 바깥 그림을 지우고 hasBlockedImages · imagesShown false — 스팸메일함 여부는 응답에 싣지 않는다', async () => { const res = await read().expect(200); const detail = dataOf(res); expect(detail.hasBlockedImages).toBe(true); expect(detail.imagesShown).toBe(false); expect(detail.htmlBody).not.toContain('e2e.example'); expect(proxiedSources(detail.htmlBody)).toEqual([]); expect(detail).not.toHaveProperty('isInJunkMailbox'); }); it('images=show 면 그림 주소를 이 서버의 중계 주소로 바꾼다 — 받은 그림은 표준 래퍼 없이 그대로, 머리글은 계약대로', async () => { const res = await read('?images=show').expect(200); const detail = dataOf(res); expect(detail.imagesShown).toBe(true); expect(detail.hasBlockedImages).toBe(false); expect(detail.htmlBody).not.toContain('e2e.example'); const [publicImage, internalImage] = proxiedSources(detail.htmlBody); expect(internalImage).toBeDefined(); const before = imageRequests.length; // superagent 는 image/* 를 Buffer 로 받는다 const image = await request(server) .get(publicImage) .set('Cookie', cookieOf(ME)) .expect(200); expect(Buffer.isBuffer(image.body)).toBe(true); expect(image.body as Buffer).toEqual(pngBytes(48, 16)); expect(image.headers['content-type']).toBe('image/png'); expect(image.headers['content-length']).toBe( String(pngBytes(48, 16).length), ); expect(image.headers['x-content-type-options']).toBe('nosniff'); expect(image.headers['cache-control']).toBe('private, max-age=3600'); expect(image.headers['content-disposition']).toBe('inline'); expect(image.headers['content-security-policy']).toBe( "default-src 'none'; sandbox", ); // 바깥으로는 확인한 주소로만 · 쿠키 · Referer 없이 expect(imageRequests).toHaveLength(before + 1); const sent = imageRequests[imageRequests.length - 1]; expect(sent.url.href).toBe(PUBLIC_IMAGE_URL); expect(sent.addresses).toEqual([ { address: '93.184.216.34', family: 4 }, ]); expect(Object.keys(sent.headers).sort()).toEqual([ 'accept', 'accept-encoding', 'user-agent', ]); // 사설 주소로 풀리는 이름은 묻지 않고 404 RES_001 (까닭을 가리지 않는다) const blocked = await request(server) .get(internalImage) .set('Cookie', cookieOf(ME)) .expect(404); expect(body(blocked)).toEqual({ success: false, error: { code: 'RES_001', message: expect.any(String) as string, traceId: blocked.headers['x-request-id'], }, }); expect(imageRequests).toHaveLength(before + 1); }); it('표는 그 직원 것만 — 다른 직원 · 고친 표 · 없는 표는 404 RES_001, 로그인하지 않았으면 401 AUTH_001', async () => { const [source] = proxiedSources( dataOf(await read('?images=show').expect(200)).htmlBody, ); const token = source.slice('/api/mail-images/'.length); const tampered = `${token.slice(0, -2)}${token.endsWith('AA') ? 'BB' : 'AA'}`; for (const [path, who] of [ [source, OTHER], [`/api/mail-images/${tampered}`, ME], ['/api/mail-images/not-a-token', ME], ] as const) { const res = await request(server) .get(path) .set('Cookie', cookieOf(who)) .expect(404); expect(body(res).error?.code).toBe('RES_001'); } const anonymous = await request(server).get(source).expect(401); expect(body(anonymous).error?.code).toBe('AUTH_001'); }); it('설정이 늘 보기(always)면 images 없이도 보인다', async () => { await patchSettings(ME, { externalImages: 'always' }).expect(200); const detail = dataOf(await read().expect(200)); expect(detail.imagesShown).toBe(true); expect(proxiedSources(detail.htmlBody)).toHaveLength(2); await patchSettings(ME, { externalImages: 'ask' }).expect(200); }); }); describe('보낸 사람 차단 (/api/mail-blocks)', () => { const ME = 'blocks.me@e2e.arasystem.test'; const add = (address: unknown, extra: object = {}) => request(server) .post('/api/mail-blocks') .set('Cookie', cookieOf(ME)) .send({ address, ...extra }); const list = async () => dataOf<{ senders: BlockedSender[]; isActive: boolean }>( await request(server) .get('/api/mail-blocks') .set('Cookie', cookieOf(ME)) .expect(200), ); it('처음에는 빈 목록 · 동작함, 로그인하지 않았으면 401 AUTH_001', async () => { await expect(list()).resolves.toEqual({ senders: [], isActive: true }); const res = await request(server).get('/api/mail-blocks').expect(401); expect(body(res).error?.code).toBe('AUTH_001'); }); it('차단 — 앞뒤 공백을 빼고 소문자로, 새로 차단하면 201 · 이미 있으면 200(처음 차단한 시각) · 목록은 최근 것부터', async () => { const created = await add(' Kim@Spam.Example ').expect(201); const kim = dataOf(created); expect(kim).toEqual({ address: 'kim@spam.example', blockedAt: expect.stringMatching(/^\d{4}-\d{2}-\d{2}T.+Z$/) as string, }); const again = await add('kim@spam.example').expect(200); expect(dataOf(again)).toEqual(kim); await add('@Ads.Example.org').expect(201); // 여러 사람이 쓰는 메일 서비스도 주소로는 막는다 await add('friend@gmail.com').expect(201); expect((await list()).senders.map(({ address }) => address)).toEqual([ 'friend@gmail.com', '@ads.example.org', 'kim@spam.example', ]); }); it.each<[string, unknown]>([ ['내 주소(대소문자 무시)', ME.toUpperCase()], ['사내 도메인', '@e2e.arasystem.test'], ['사내 하위 도메인', '@mail.e2e.arasystem.test'], ['사내 직원 주소', 'doyun.lee@e2e.arasystem.test'], ['여러 사람이 쓰는 메일 서비스의 도메인', '@Naver.com'], ['주소 모양이 아님', 'not an address'], ['${ 가 든 주소', '${x}@spam.example'], ['IP 주소 도메인', '@127.0.0.1'], ['점 없는 도메인', '@localhost'], ['255자', `${'a'.repeat(240)}@spam.example`], ['빈 글', ' '], ['글이 아님', 42], ])( '막을 수 없거나 모양이 틀리면 400 VAL_001 — %s', async (_label, address) => { const res = await add(address).expect(400); expect(body(res).error?.code).toBe('VAL_001'); }, ); it('모르는 칸이 있으면 400 VAL_001', async () => { const res = await add('lee@spam.example', { note: '메모' }).expect(400); expect(body(res).error?.code).toBe('VAL_001'); }); it('차단 풀기 — URL 인코딩한 주소(대소문자 무시)는 200 { address }, 없거나 모양이 틀리면 404 RES_001', async () => { await add('lee@spam.example').expect(201); const removed = await request(server) .delete(`/api/mail-blocks/${encodeURIComponent('LEE@spam.example')}`) .set('Cookie', cookieOf(ME)) .expect(200); expect(body(removed)).toEqual({ success: true, data: { address: 'lee@spam.example' }, }); expect( (await list()).senders.map(({ address }) => address), ).not.toContain('lee@spam.example'); // 도메인 차단도 같은 길 — `@` 를 인코딩한 경로(%40…)를 풀어 도메인으로 맞춘다 (대소문자 무시) await add('@Domain-Spam.example').expect(201); const domainRemoved = await request(server) .delete('/api/mail-blocks/%40DOMAIN-spam.example') .set('Cookie', cookieOf(ME)) .expect(200); expect(body(domainRemoved)).toEqual({ success: true, data: { address: '@domain-spam.example' }, }); expect( (await list()).senders.map(({ address }) => address), ).not.toContain('@domain-spam.example'); for (const path of [ encodeURIComponent('lee@spam.example'), encodeURIComponent('not an address'), ]) { const res = await request(server) .delete(`/api/mail-blocks/${path}`) .set('Cookie', cookieOf(ME)) .expect(404); expect(body(res).error?.code).toBe('RES_001'); } }); }); }); /** * 출처 검사(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'], ['patch', '/api/contacts/abc'], ['post', '/api/contact-restorations'], ['patch', '/api/mail-settings'], ['post', '/api/mail-blocks'], ['delete', '/api/mail-blocks/kim%40spam.example'], ['delete', '/api/scheduled-messages/eaaaaab'], ] 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); }); }); });