Files
arasystem/backend/test/app.e2e-spec.ts
T
ttipo 9887035bcd feat(backend): 메일 검색 색인의 바탕(표 · 맞추기 · 지난 메일 채우기 · 준비 상태)을 더한다
14단계 자체 검색 색인의 1차다. 검색 API 를 색인으로 바꾸는 일과 대화 키 잇기는 다음 차례다.

- 마이그레이션 AddMailSearchIndex 하나: 표 mail_search_messages · mail_search_states 와
  mail_settings.group_by_conversation 열(3차의 대화로 묶어 보기 설정 — 같은 배포에서 한 번에 만든다)
- 접기 · 글자 두 개 묶음 · 맞은 곳 · 말머리 떼기 순수 함수와 표본 표(나중에 frontend 와 짝)
- 엔진 포트 MAIL_INDEX_SOURCE + JMAP 어댑터: Email/changes 맞추기(실시간 · 5분 · 검색 전,
  서버 전체 동시 4), 밤 창(KST 22:00~02:45 · 05:00~07:30) 지난 메일 채우기, 전체 대조
- GET /api/search-index, 로그인 · 새 비밀번호 때 상태 줄, 퇴사 마무리에 표 둘
- 로그 가림에 조건 검색 이름 넷, 개발 환경 DB 쿼리 로그 끔(메일 글이 DB 에 든다)

리뷰 반영: 시간 끝은 실패가 아니라 이어 하기, 본문 읽기 묶음의 메일 크기 상한(32MiB ·
16MiB 넘으면 혼자), 겹친 id 빼기, 사내 도메인 주소만 색인(개발 서버 cron 이 e2e 줄을 건드리지 않게).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:51:15 +09:00

8426 lines
323 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { NestExpressApplication } from '@nestjs/platform-express';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { createHash, createHmac, randomBytes, randomUUID } from 'crypto';
import { existsSync, mkdtempSync, rmSync, unlinkSync } from 'fs';
import { get as httpGet, type IncomingMessage, type Server } from 'http';
import { connect } from 'net';
import { tmpdir } from 'os';
import { basename, join } from 'path';
import { Readable } from 'stream';
import request from 'supertest';
import type { Response, Test as HttpTest } from 'supertest';
import { App } from 'supertest/types';
import { DataSource } from 'typeorm';
import { SchedulerRegistry } from '@nestjs/schedule';
import { AppModule } from '@/app.module';
import { configureApp, createHttpAdapter } from '@/app.setup';
import { BusinessException } from '@/common/errors/business.exception';
import {
ACCOUNT_SECURITY_PROVIDER,
type AppPasswordRecord,
type CreatedAppPassword,
type TwoFactorDisableResult,
type TwoFactorEnableResult,
} from '@/modules/account-security/account-security.provider';
import {
ACCOUNT_SECURITY_SETTINGS,
type AccountSecuritySettings,
} from '@/modules/account-security/account-security.settings';
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
import { MAX_CONTACTS } from '@/modules/contacts/contacts.constants';
import { DirectoryService } from '@/modules/contacts/directory.service';
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 { FileSharingRepository } from '@/modules/file-sharing/file-sharing.repository';
import {
FILE_SHARING_SETTINGS,
type FileSharingSettings,
} from '@/modules/file-sharing/file-sharing.settings';
import { FILE_CHUNK_SIZE } from '@/modules/file-storage/file-storage.constants';
import { FileStorageService } from '@/modules/file-storage/file-storage.service';
import {
FILE_STORAGE_SETTINGS,
MEGABYTE,
type FileStorageSettings,
} from '@/modules/file-storage/file-storage.settings';
import {
MAX_BODY_HTML_LENGTH,
MAX_BODY_LENGTH,
MAX_BULK_MESSAGE_IDS,
MAX_SEARCH_LENGTH,
MAX_SEARCH_WORDS,
MAX_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH,
MAX_VACATION_SUBJECT_LENGTH_WHEN_OFF,
MAX_VACATION_TEXT_LENGTH,
MAX_VACATION_TEXT_LENGTH_WHEN_OFF,
} from '@/modules/mail/mail.constants';
import {
MAIL_IMAGE_RESOLVER,
MAIL_IMAGE_TRANSPORT,
type MailImageRequest,
type MailImageResponse,
type ResolvedAddress,
} from '@/modules/mail/images/mail-image-fetcher';
import {
AccountLoginDisabledException,
MAIL_PROVIDER,
PasswordExpiredException,
} 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,
CredentialCheck,
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 {
MAIL_INDEX_SOURCE,
type IndexDocument,
type MailIndexSource,
} from '@/modules/mail-search/mail-index-source';
import { MailSearchBuildService } from '@/modules/mail-search/mail-search-build.service';
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
import { toIndexRow } from '@/modules/mail-search/index-document';
import {
SystemSettingsService,
type SystemSettingsPatch,
} from '@/modules/system-settings/system-settings.service';
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
import { createCidrMatcher, parseCidr } from '@/shared/utils/cidr';
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
import { kstDateString, kstStartOfDate } from '@/shared/utils/kst-date';
import {
buildOtpauthUrl,
encodeBase32,
totpCode,
totpCounterAt,
verifyTotp,
} from '@/shared/utils/totp';
import { pngBytes } from './image-fixtures';
/**
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
*
* ⚠️ DB 연결이 필요하다 (AppModule 이 TypeORM 을 초기화한다).
* 로컬: docker 개발 스택을 띄운 뒤 실행. CI: postgres 서비스 컨테이너 사용.
* 시작할 때 마이그레이션을 실행한다 — 빈 DB(CI)에도 로그인 세션 표가 생긴다 (이미 적용됐으면 건너뛴다).
*
* 메일 엔진(Stalwart)은 띄우지 않는다 — 외부 시스템이므로 포트(MAIL_PROVIDER)를 대역으로 바꾸고,
* 메일 설정(MAIL_SETTINGS)도 고정해 env 와 무관하게(CI 에는 MAIL_* 가 없다) 같은 조건으로 돈다.
* 실제 엔진과의 연동은 개발 스택에서 따로 확인한다 (backend-dev 통합 확인).
*/
/** supertest 의 body 는 any 이므로 계약 형태로 좁혀서 읽는다 */
interface ContractBody {
success?: boolean;
data?: {
items?: unknown[];
pageInfo?: {
hasNext: boolean;
nextCursor: string | null;
total: number | null;
};
email?: string;
name?: string;
fileName?: string;
originalName?: string;
} | null;
error?: { code?: string; message?: string; traceId?: string };
status?: string;
details?: unknown;
}
const body = (res: Response): ContractBody => res.body as ContractBody;
/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */
const dataOf = <T>(res: Response): T => (res.body as { data: T }).data;
/** Set-Cookie 헤더 중 이름이 같은 것 */
function setCookie(res: Response, name: string): string | undefined {
const raw = res.headers['set-cookie'] as unknown;
const cookies = Array.isArray(raw) ? (raw as string[]) : [];
return cookies.find((cookie) => cookie.startsWith(`${name}=`));
}
/** Set-Cookie 의 값 부분 */
function cookieValue(cookie: string | undefined): string {
return (cookie ?? '').split(';')[0].split('=').slice(1).join('=');
}
const EMPLOYEE = { email: 'ara.kim@e2e.arasystem.test', name: '김아라' };
/** 13단계 — 로그인(signedIn) · me · 갱신 응답은 세션의 제한 · 2단계 필수 유예를 늘 싣는다 (제한 없음 · 유예 아님) */
const UNRESTRICTED = { restriction: null, twoFactorDeadline: null };
/** 대역 메일 엔진이 받아 주는 비밀번호 (그 밖의 비밀번호는 틀린 것으로 본다) */
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<string, RecipientRejectionReason>([
[NOBODY, 'not-found'],
[BUSY, 'temporary'],
]);
/** 대역 메일 엔진이 "만든 엔진 규칙이 엔진 한도를 넘는다"고 답하는 필터의 찾을 글 (400 MAIL_013 경로) */
const ENGINE_TOO_LARGE_FILTER_VALUE = 'engine-says-too-large';
/** 대역 메일 엔진의 메일함 — 필터가 옮길 수 없는 보낸편지함(e) · 임시보관함(d) 확인용 */
const E2E_MAILBOXES: Mailbox[] = (
[
['a', 'inbox'],
['b', 'trash'],
['c', 'junk'],
['d', 'drafts'],
['e', 'sent'],
['j', null],
] as const
).map(([id, role]) => ({
id,
name: id,
role,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
}));
/** 대역 메일 엔진이 "다른 규칙의 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<string, ResolvedAddress[]>([
['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', '박서준'],
]);
/**
* 12단계 — 엔진 웹훅 서명 키(영문 · 숫자 32자 이상)와 e2e 의 접속 주소(supertest 는 127.0.0.1 로 붙는다)를 받는 앱 네트워크 대역.
* 테스트가 대역 밖으로 바꿔 볼 수 있게 열어 둔다 (e2eAppNetworkOpen)
*/
const E2E_WEBHOOK_SECRET = 'E2eWebhookSecret0000000000000000000';
let e2eAppNetworkOpen = true;
const e2eLoopback = createCidrMatcher(parseCidr('127.0.0.0/8')!);
const TEST_ACCOUNT_SECURITY_SETTINGS: AccountSecuritySettings = {
webhookSecret: E2E_WEBHOOK_SECRET,
appNetwork: parseCidr('127.0.0.0/8')!,
isAppNetworkAddress: (address) => e2eAppNetworkOpen && e2eLoopback(address),
};
/** base32(RFC 4648) → 바이트 — 등록 응답의 비밀로 인증 앱 코드를 만든다 */
function decodeBase32(text: string): Buffer {
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
let bits = 0;
let value = 0;
const bytes: number[] = [];
for (const char of text) {
value = ((value << 5) | alphabet.indexOf(char)) & 0xffff;
bits += 5;
if (bits >= 8) {
bytes.push((value >>> (bits - 8)) & 0xff);
bits -= 8;
}
}
return Buffer.from(bytes);
}
/** otpauth 주소의 비밀(바이트) */
function secretOfOtpauth(url: string): Buffer {
return decodeBase32(new URL(url).searchParams.get('secret') ?? '');
}
/** 비밀로 만든 인증 앱 코드 — offset 단계만큼 앞 · 뒤 (앞뒤 한 단계는 받는다) */
function otpCodeOf(secret: Buffer, offset = 0): string {
return totpCode(secret, totpCounterAt(Date.now()) + offset);
}
/** 문을 열 조건을 다시 보는 간격 */
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;
/**
* 앱을 띄우는 준비(맨 바깥 beforeAll)의 시간 제한 — DB 연결 · 마이그레이션, 그리고 TypeORM 이 그때 읽는 엔티티 · 마이그레이션 파일의 ts-jest 변환이 들어 있다.
* 변환 캐시가 빈 실행을 바인드 마운트 너머(개발 컨테이너)에서 돌리면 jest 기본값(5초)을 넘어 모든 테스트가 실패했다 (2026-11-02, 13단계)
*/
const APP_SETUP_TIMEOUT_MS = 60_000;
/** 열 때까지 기다리게 하는 문 — 한 번 열면 기다리던 쪽이 모두 풀린다 */
function createGate(): { readonly opened: Promise<void>; open: () => void } {
let open!: () => void;
const opened = new Promise<void>((resolve) => {
open = resolve;
});
return { opened, open };
}
/** 조건이 맞거나 기한이 지날 때까지 기다린다 — 맞았는지는 뒤의 단언이 본다 */
async function waitUntil(
condition: () => boolean,
deadlineMs: number,
): Promise<void> {
const deadline = Date.now() + deadlineMs;
while (!condition() && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, GATE_POLL_MS));
}
}
/** 서버를 빈 포트로 연다 */
function listenOnAnyPort(server: Server): Promise<void> {
return new Promise((resolve) => server.listen(0, () => resolve()));
}
/** 열린 실시간 알림 연결 — 응답 머리와 받은 글자를 모은다 */
interface EventStreamClient {
readonly response: IncomingMessage;
/** 지금까지 받은 글자 */
received(): string;
/** 클라이언트가 끊는다 */
close(): void;
}
/**
* 실시간 알림(SSE)을 연다 — 닫을 때까지 끝나지 않는 응답이라 supertest(본문을 다 받을 때까지 기다린다) 대신 http.get 으로 읽는다.
* 연결을 모아 두지 않는다(agent: false) — 끊으면 바로 서버에서도 끊긴다
*/
function openEventStream(
server: Server,
cookie: string,
): Promise<EventStreamClient> {
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
return new Promise((resolve, reject) => {
const request = httpGet(
{
host: '127.0.0.1',
port,
path: '/api/mail-events',
headers: { cookie, accept: 'text/event-stream' },
agent: false,
},
(response) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
text += chunk;
});
// 끊은 뒤의 오류(ECONNRESET 등)는 테스트와 상관없다
response.on('error', () => undefined);
resolve({
response,
received: () => text,
close: () => request.destroy(),
});
},
);
request.on('error', reject);
});
}
/** 서버를 닫는다 — 열린 연결이 모두 끝나야 끝난다 */
function closeServer(server: Server): Promise<void> {
return new Promise((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
}
/**
* 이 테스트가 만드는 세션의 주소 — 끝나면 지운다 (개발 DB 에 남기지 않는다).
* 개발 메일 도메인(arasystem.test)과 **다른** 도메인을 쓴다 — 같으면 개발 중인 사람의 실제 세션까지 지워진다.
*/
const TEST_DOMAIN_PATTERN = '%@e2e.arasystem.test';
/** e2e 전용 메일 설정 — 대역 엔진과 함께 쓴다 (주소 정책만 의미가 있다) */
const TEST_MAIL_SETTINGS: MailSettings = {
jmapBaseUrl: null,
domain: 'e2e.arasystem.test',
gatewayUser: 'mail-gateway@e2e.arasystem.test',
gatewayPassword: null,
attachmentMaxBytes: 20 * 1024 * 1024,
attachmentTotalMaxBytes: 25 * 1024 * 1024,
blockedAccounts: new Set([ADMIN_ACCOUNT, DIRECTORY_ACCOUNT]),
directoryAccount: DIRECTORY_ACCOUNT,
};
/** 11단계 — 대용량 첨부의 공개 호스트. 포털과 다른 이름이라 공개 호스트 막기(공개 호스트는 공개 경로만 · 포털은 공개 경로를 닫는다)도 함께 본다 */
const E2E_SHARE_HOST = 'files.e2e.arasystem.test';
const E2E_SHARE_ORIGIN = `https://${E2E_SHARE_HOST}`;
/** e2e 전용 공개 링크 설정 — 파일당 내려받기 상한은 작게 둬서 상한에 닿는 것을 본다 */
const TEST_FILE_SHARING_SETTINGS: FileSharingSettings = {
publicOrigin: E2E_SHARE_ORIGIN,
reportAddress: 'postmaster@e2e.arasystem.test',
downloadLimit: 3,
dedicatedPublicHost: E2E_SHARE_HOST,
};
/**
* e2e 전용 파일 저장 설정 — 폴더는 시작할 때 임시로 만들고 끝나면 지운다(개발 볼륨에 남기지 않는다).
* 검사기는 쓰지 않는다(다 올리면 곧 unscannable — DB 의 검사 차례가 개발 중인 사람의 파일까지 집지 않게). 검사 중 · 위험은 표의 값을 바꿔 본다
*/
const testFileStorageSettings = (dir: string): FileStorageSettings => ({
dir,
maxFileBytes: 2047 * MEGABYTE,
maxFilesPerMessage: 2,
userQuotaBytes: 64 * MEGABYTE,
retentionDays: 30,
serverMaxTotalBytes: null,
minFreeBytes: 1,
clamav: null,
});
/**
* 14단계 — 검색 색인의 대역 엔진: 직원마다 메일 글과 state. 실제 엔진 읽기(JMAP)는 jmap-index-source.spec.ts 가 본다.
* 넣지 않은 직원은 엔진 장애(MAIL_001)로 답한다 — 개발 DB 를 함께 쓰는 실제 직원의 줄을 대역으로 채우지 않게
*/
class FakeIndexSource implements MailIndexSource {
readonly mailboxes = new Map<
string,
{ state: string; emails: IndexDocument[] }
>();
private mailbox(user: string) {
const found = this.mailboxes.get(user);
if (!found) {
throw new BusinessException('MAIL_001', {
context: { reason: 'e2e-unknown-index-user' },
});
}
return found;
}
snapshot(user: string) {
const { state, emails } = this.mailbox(user);
return Promise.resolve({ state, total: emails.length });
}
changes(user: string, sinceState: string) {
const { state } = this.mailbox(user);
// 같은 state 면 변화 없음, 다르면 셀 수 없음(전체 대조로)
return Promise.resolve(
sinceState === state
? {
kind: 'changes' as const,
created: [],
updated: [],
destroyed: [],
newState: state,
hasMore: false,
}
: { kind: 'cannotCalculate' as const },
);
}
documents(user: string, ids: readonly string[]) {
const { emails } = this.mailbox(user);
return Promise.resolve(emails.filter((email) => ids.includes(email.id)));
}
mutable(user: string, ids: readonly string[]) {
return this.documents(user, ids);
}
pageIds(
user: string,
from: { anchor: string | null; position: number },
limit: number,
) {
const ids = this.newestFirst(user);
const anchorAt = from.anchor === null ? -1 : ids.indexOf(from.anchor);
const start = anchorAt >= 0 ? anchorAt + 1 : from.position;
return Promise.resolve({
ids: ids.slice(start, start + limit),
position: start,
total: ids.length,
});
}
allIds(user: string) {
const { state } = this.mailbox(user);
return Promise.resolve({ state, ids: this.newestFirst(user) });
}
private newestFirst(user: string): string[] {
return [...this.mailbox(user).emails]
.sort((a, b) => Date.parse(b.receivedAt) - Date.parse(a.receivedAt))
.map((email) => email.id);
}
}
/** 14단계 — 색인에 넣을 메일 한 통 (대역) */
function indexDocument(overrides: Partial<IndexDocument>): IndexDocument {
return {
id: 'e2e-mail',
threadId: 'e2e-thread',
mailboxIds: ['inbox'],
keywords: [],
receivedAt: '2026-11-05T01:00:00Z',
size: 100,
messageIds: [],
inReplyTo: [],
references: [],
subject: '',
from: [{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' }],
to: [],
cc: [],
attachmentNames: [],
hasAttachment: false,
preview: '',
bodyText: '',
...overrides,
};
}
/** 목록 응답의 data — 계약의 { items, pageInfo } */
interface Page<T> {
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;
/** 11단계 — 이 테스트의 파일 저장 폴더 (시작할 때 만들고 끝나면 지운다) */
let fileDir = '';
/** 대역 엔진이 비밀번호를 확인한 횟수 (주소별) — 확인이 엔진에 도착한 때 는다 */
const engineChecks = new Map<string, number>();
/**
* 대역 엔진의 SLOW_ACCOUNT 확인이 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다.
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
*/
let slowEngineGate: Promise<void> | null = null;
/** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */
let slowMailboxGate: Promise<void> | null = null;
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
const drafts = new Map<
string,
{ user: string; content: DraftContent; updatedAt: string }
>();
let draftSequence = 0;
/** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */
const deletedDrafts: string[] = [];
/** 대역 엔진이 받은 발송 */
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 받는 사람을 모두 받지 않은 발송 — 보내지 않은 것이라 sentMessages 에 넣지 않는다 */
const refusedSends: { user: string; message: OutgoingMessage }[] = [];
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
const correspondents = new Map<string, Correspondence[]>();
/**
* 11단계 — 대역 엔진의 보낸 사본: 발송 응답의 id(`sent<차례>`)로 그 메일을 찾는다. 보낸 사람과 받는 사람(to · cc)이 읽는다
* (받는 사람의 사본도 같은 메일이다 — 계약 4절 머리말)
*/
const sentCopy = (user: string, id: string) => {
const match = /^sent(\d+)$/.exec(id);
const sent = match ? sentMessages[Number(match[1]) - 1] : undefined;
if (!sent) return undefined;
const readers = [
sent.user,
...[...sent.message.to, ...sent.message.cc].map(({ email }) => email),
];
return readers.includes(user) ? sent : undefined;
};
/** 보낸 사본을 읽은 메일 (보낸 사람 = 보낸 직원 — 대용량 첨부 덩이 알아보기의 "보낸 사람 = 링크 주인") */
const sentDetailOf = (
id: string,
sent: { user: string; message: OutgoingMessage },
): MessageDetailSource => ({
id,
threadId: id,
mailboxIds: ['e'],
from: { name: '테스트 직원', email: sent.user },
to: sent.message.to,
subject: sent.message.subject,
preview: '',
receivedAt: '2026-10-30T01:00:00.000Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 1_000,
pendingSend: null,
cc: sent.message.cc,
bcc: [],
replyTo: [],
sentAt: null,
messageId: `${id}@e2e`,
references: [],
rawHtmlBody: sent.message.htmlBody ?? null,
isRawHtmlBodyTruncated: false,
textBody: sent.message.body,
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
isInJunkMailbox: false,
});
/** 취소해 되돌린 발송의 초안 (9단계 — 같은 id · 취소 표시). 보낸 사본에는 대용량 파일 머리글이 없다 */
const canceledDraftOf = (
id: string,
message: OutgoingMessage,
): DraftSource => ({
id,
to: message.to,
cc: message.cc,
bcc: message.bcc,
subject: message.subject,
textBody: message.body,
rawHtmlBody: message.htmlBody ?? null,
attachments: [],
inReplyTo: null,
updatedAt: '2026-10-30T01:00:00.000Z',
isCanceledSend: true,
largeFileIds: [],
});
/** 대역 엔진이 받은 메일 목록 요청 (검색 범위·검색어 확인용) */
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<string>();
const filterRuns: { user: string; request: MailFilterRunRequest }[] = [];
/** 대역 엔진의 적용이 테스트가 문을 열 때까지 끝나지 않는다 — 같은 직원의 진행 중 잠금 확인용. null 이면 바로 답한다 */
let slowFilterRunGate: Promise<void> | null = null;
const singleUpdates: {
user: string;
id: string;
update: MessageUpdate;
}[] = [];
/** 9단계 — 대역 엔진의 차단 목록(직원마다 — 최근 것부터) · 예약한 메일 · 취소한 발송 · 바깥 이미지 요청 */
const mailBlocks = new Map<string, BlockedSender[]>();
const scheduledMessages = new Map<string, MessageSummary[]>();
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<ResolvedAddress[]> => {
const addresses = IMAGE_HOSTS.get(hostname);
return addresses
? Promise.resolve(addresses)
: Promise.reject(new Error('e2e-unknown-host'));
},
};
const fakeImageTransport = {
get: (imageRequest: MailImageRequest): Promise<MailImageResponse> => {
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,
});
},
};
/** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */
const twoFactorUrls = new Map<string, string>();
/** 13단계 — 대역 엔진에서 비밀번호가 만료된 직원 (메일 기능이 막히고, 본인이 바꾸면 엔진이 만료를 푼다) */
const expiredPasswords = new Set<string>();
/** 13단계 — 대역 엔진에서 잠긴 직원 (로그인 권한을 뺐다 — 대리 접근 403, 게이트웨이는 정상) */
const lockedAccounts = new Set<string>();
const appPasswordsByUser = new Map<string, AppPasswordRecord[]>();
let appPasswordSequence = 0;
/** 엔진 기본 상한 (x:Authentication.maxAppPasswords) */
const E2E_MAX_APP_PASSWORDS = 5;
const fakeAccountSecurityProvider = {
isTwoFactorEnabled: (user: string) =>
Promise.resolve(twoFactorUrls.has(user)),
enableTwoFactor: (
user: string,
currentPassword: string,
otpauthUrl: string,
): Promise<TwoFactorEnableResult> => {
// 엔진처럼 코드를 보지 않고 저장한다 — 이미 켜져 있으면(지금 코드를 요구) 거절
if (currentPassword !== CORRECT_PASSWORD || twoFactorUrls.has(user)) {
return Promise.resolve('refused');
}
twoFactorUrls.set(user, otpauthUrl);
return Promise.resolve('enabled');
},
disableTwoFactor: (
user: string,
currentPassword: string,
otpCode: string,
): Promise<TwoFactorDisableResult> => {
const url = twoFactorUrls.get(user);
if (
currentPassword !== CORRECT_PASSWORD ||
url === undefined ||
verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) === null
) {
return Promise.resolve('code-rejected');
}
twoFactorUrls.delete(user);
return Promise.resolve('disabled');
},
listAppPasswords: (user: string) =>
Promise.resolve([...(appPasswordsByUser.get(user) ?? [])]),
createAppPassword: (
user: string,
name: string,
): Promise<CreatedAppPassword> => {
const list = appPasswordsByUser.get(user) ?? [];
if (list.length >= E2E_MAX_APP_PASSWORDS) {
return Promise.reject(
new BusinessException('AUTH_009', {
context: { reason: 'e2e-app-password-limit' },
}),
);
}
appPasswordSequence += 1;
const record = {
id: String.fromCharCode(97 + (appPasswordSequence % 26)) + 'e2e',
name,
createdAt: new Date(
Date.UTC(2026, 9, 31, 0, 0, appPasswordSequence),
).toISOString(),
};
appPasswordsByUser.set(user, [...list, record]);
return Promise.resolve({
...record,
password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
});
},
deleteAppPassword: (user: string, id: string) => {
appPasswordsByUser.set(
user,
(appPasswordsByUser.get(user) ?? []).filter(
(record) => record.id !== id,
),
);
return Promise.resolve();
},
deleteAllAppPasswords: (user: string) => {
const count = (appPasswordsByUser.get(user) ?? []).length;
appPasswordsByUser.set(user, []);
return Promise.resolve(count);
},
};
/** 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<string, AddressBookContactCard>;
groups: Map<string, AddressBookGroupCard>;
}
>();
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<AddressBookSnapshot> => {
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<AddressBookWriteOutcome> => {
const book = addressBookOf(user);
if (version !== `v${book.state}`) {
return Promise.resolve({ kind: 'conflict' });
}
const createdIds = new Map<string, string>();
const cards = new Map<string, AddressBookCard>();
const removed = new Map<string, RemovedCardOriginal>();
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'));
/** 14단계 — 검색 색인의 대역 엔진 */
const fakeIndexSource = new FakeIndexSource();
beforeAll(async () => {
fileDir = mkdtempSync(join(tmpdir(), 'arasystem-e2e-files-'));
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
})
.overrideProvider(MAIL_PROVIDER)
.useValue({
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다.
// 13단계 — 비밀번호가 만료된 계정은 어댑터가 PasswordExpiredException 으로 알린다(메일 메서드가 forbidden)
getProfile: (email: string) =>
lockedAccounts.has(email)
? Promise.reject(new AccountLoginDisabledException(email))
: expiredPasswords.has(email)
? Promise.reject(
new PasswordExpiredException(
email,
PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
),
)
: Promise.resolve({
email,
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
}),
verifyCredentials: async (
email: string,
password: string,
): Promise<CredentialCheck> => {
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
if (password !== CORRECT_PASSWORD) return { kind: 'invalid' };
// 12단계 — 2단계 인증을 켠 직원은 비밀번호가 맞아도 402 (엔진이 코드를 요구한다)
return twoFactorUrls.has(email)
? { kind: 'two-factor-required' }
: { kind: 'valid', account: email };
},
// 12단계 — 엔진의 로그인 요청(비밀번호 + 코드). 엔진처럼 같은 코드를 다시 받는다(막기는 서버 몫)
verifyTwoFactorLogin: (
email: string,
password: string,
otpCode: string,
) => {
const url = twoFactorUrls.get(email);
return Promise.resolve(
password === CORRECT_PASSWORD &&
url !== undefined &&
verifyTotp(secretOfOtpauth(url), otpCode, Date.now()) !== null
? ('authenticated' as const)
: ('code-rejected' as const),
);
},
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다.
// 받는 사람별 결과는 ENGINE_REJECTIONS 로 흉내 낸다 — 모두 받지 않으면 어댑터처럼 MAIL_010(5xx 가 섞임) · MAIL_011(모두 4xx)
findSentMessage: () => Promise.resolve(null),
getRejectedRecipients: () => Promise.resolve([]),
sendMessage: (
user: string,
message: OutgoingMessage,
): Promise<SentMessage> => {
const recipients = [...message.to, ...message.cc, ...message.bcc];
const rejectedRecipients = recipients.flatMap(({ email }) => {
const reason = ENGINE_REJECTIONS.get(email);
return reason ? [{ email, reason }] : [];
});
if (rejectedRecipients.length === recipients.length) {
refusedSends.push({ user, message });
const code = rejectedRecipients.some(
({ reason }) => reason !== 'temporary',
)
? 'MAIL_010'
: 'MAIL_011';
return Promise.reject(
new BusinessException(code, {
context: { reason: 'e2e-all-recipients-rejected' },
}),
);
}
sentMessages.push({ user, message });
// 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<SavedDraft> => {
if (replaceId === DRAFT_CHANGED_ELSEWHERE) {
return Promise.reject(
new BusinessException('MAIL_008', {
context: { reason: 'e2e-draft-replaced-elsewhere' },
}),
);
}
if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-not-a-draft' },
}),
);
}
draftSequence += 1;
const id = `draft${draftSequence}`;
const updatedAt = new Date().toISOString();
drafts.set(id, { user, content, updatedAt });
if (replaceId !== undefined) drafts.delete(replaceId);
return Promise.resolve({ id, attachments: [], updatedAt });
},
getDraft: (user: string, id: string): Promise<DraftSource> => {
const draft = drafts.get(id);
if (!draft || draft.user !== user) {
// 9단계 — 취소한 발송은 같은 id 의 초안으로 돌아온다 (11단계 덩이 떼기 확인용 — 실제 되돌리기는 jmap-mail.provider.spec.ts)
const canceled = canceledSends.some(
(entry) => entry.user === user && entry.id === id,
)
? sentCopy(user, id)
: undefined;
if (canceled?.user === user) {
return Promise.resolve(canceledDraftOf(id, canceled.message));
}
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,
largeFileIds: [...(content.largeFileIds ?? [])],
});
},
deleteDraft: (user: string, id: string) => {
deletedDrafts.push(id);
if (drafts.get(id)?.user === user) drafts.delete(id);
return Promise.resolve();
},
listCorrespondents: (user: string) =>
Promise.resolve(correspondents.get(user) ?? []),
// 3단계 — 엔진 쪽 판단(검색 조건 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다. 여기서는 받은 값만 기록한다
getMailState: () => Promise.resolve({ state: MAIL_STATE }),
// 5단계 — 엔진 연결(다시 잇기 · 새 메일 계산)은 jmap-mail-watch.spec.ts · jmap-mail.provider.spec.ts 가 본다.
// 여기서는 처음 상태만 주고, 소식은 테스트가 listener 로 흘려 넣는다
watchMail: (
user: string,
listener: MailWatchListener,
): Promise<MailWatch> => {
if (user === EVENTS_ENGINE_DOWN) {
return Promise.reject(
new BusinessException('MAIL_001', {
context: { reason: 'e2e-engine-down' },
}),
);
}
const watch = { user, listener, closed: false };
liveWatches.push(watch);
return Promise.resolve({
initial: { state: MAIL_STATE, inboxUnread: 2 },
close: () => {
watch.closed = true;
},
});
},
listMessages: (user: string, query: MessageListQuery) => {
listQueries.push({ user, query });
return Promise.resolve({
items: [],
hasNext: false,
nextCursor: null,
total: 0,
});
},
updateMessages: (
user: string,
ids: readonly string[],
update: MessageUpdate,
): Promise<MessagesPatchResult> => {
bulkUpdates.push({ user, ids, update });
if (update.mailboxId === MISSING_MAILBOX) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-mailbox-not-found' },
}),
);
}
// 붙잡힌 메일은 옮기기 · 스팸 표시를 하지 않고 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<MessageDeletionResult> => {
trashDeletions.push({ user, ids });
return Promise.resolve({
deletedIds: ids.filter((id) => id !== GONE_MESSAGE),
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
});
},
// 4단계 — 엔진 쪽 판단(본인 자격의 비밀번호 바꾸기 · 조건부로 옮기기 · 비우기)은 jmap-*.spec.ts 가 본다. 여기서는 받은 값만 흉내 낸다
changePassword: (
email: string,
currentPassword: string,
newPassword: string,
options: { readonly otpCode?: string } = {},
): Promise<PasswordChangeOutcome> => {
// 12단계 — 2단계 인증을 켠 직원은 코드가 없으면 바꾸지 않고(402), 코드가 틀리면 엔진이 거절한다
const url = twoFactorUrls.get(email);
if (url !== undefined) {
if (options.otpCode === undefined) {
return Promise.resolve('two-factor-required');
}
if (
verifyTotp(secretOfOtpauth(url), options.otpCode, Date.now()) ===
null
) {
return Promise.resolve('code-rejected');
}
}
passwordChanges.push({ email, newPassword });
if (currentPassword !== CORRECT_PASSWORD) {
return Promise.resolve('current-password-rejected');
}
const outcomes = new Map<string, PasswordChangeOutcome>([
[ENGINE_REJECTED_PASSWORD, 'new-password-rejected'],
[ENGINE_REFUSED_PASSWORD, 'current-password-refused'],
[ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'],
]);
const outcome = outcomes.get(newPassword) ?? 'changed';
// 13단계 — 엔진은 본인이 비밀번호를 바꾸면 만료를 스스로 푼다
if (outcome === 'changed') expiredPasswords.delete(email);
return Promise.resolve(outcome);
},
createMailbox: (
user: string,
input: MailboxCreate,
): Promise<Mailbox> => {
mailboxCalls.push({ user, action: 'create', args: [input] });
if (input.name === TAKEN_MAILBOX_NAME) {
return Promise.reject(
new BusinessException('MAIL_009', {
context: { reason: 'e2e-mailbox-name-taken' },
}),
);
}
if (input.parentId === MISSING_MAILBOX) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-parent-not-found' },
}),
);
}
return Promise.resolve({
id: 'newbox',
name: input.name,
role: null,
parentId: input.parentId,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
});
},
renameMailbox: (
user: string,
id: string,
name: string,
): Promise<Mailbox> => {
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
return Promise.resolve({
id,
name,
role: null,
parentId: null,
sortOrder: 0,
totalCount: 3,
unreadCount: 1,
});
},
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
mailboxCalls.push({ user, action: 'delete', args: [id] });
return Promise.resolve({
deleted: true,
processed: 4,
movedToTrash: 3,
remaining: 0,
});
},
markMailboxRead: (
user: string,
id: string,
): Promise<MailboxBulkResult> => {
mailboxCalls.push({ user, action: 'mark-read', args: [id] });
return Promise.resolve({ processed: 5, remaining: 0 });
},
emptyMailbox: async (
user: string,
id: string,
): Promise<MailboxBulkResult> => {
mailboxCalls.push({ user, action: 'empty', args: [id] });
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
return { processed: 2, remaining: 0 };
},
getVacation: (user: string): Promise<MailVacationState> =>
Promise.resolve({
...(mailScripts.get(user)?.vacation ?? NEVER_CONFIGURED_VACATION),
isActive: !inactiveScripts.has(user),
}),
// 부재중만 바꾼다 — 같은 엔진 규칙의 필터 규칙은 그대로 (실제 규칙은 jmap-filter-script.spec.ts)
setVacation: (
user: string,
vacation: MailVacation,
): Promise<MailVacationState> => {
vacationSaves.push({ user, vacation });
mailScripts.set(user, {
rules: mailScripts.get(user)?.rules ?? [],
vacation,
});
inactiveScripts.delete(user);
return Promise.resolve({ ...vacation, isActive: true });
},
listMailboxes: (): Promise<Mailbox[]> => Promise.resolve(E2E_MAILBOXES),
// 6단계 — 엔진 쪽 판단(지문 비교 · 한도)만 흉내 낸다. 만드는 Sieve · 조건부 쓰기는 jmap/*.spec.ts 가 본다
getMailFilters: (user: string): Promise<MailFilterSet> => {
const rules = mailScripts.get(user)?.rules ?? [];
return Promise.resolve({
rules,
version: fakeFilterVersion(rules),
isActive: true,
});
},
setMailFilters: (
user: string,
update: MailFilterUpdate,
): Promise<MailFilterSet> => {
filterSaves.push({ user, update });
const current = mailScripts.get(user);
const version = fakeFilterVersion(current?.rules ?? []);
if (
version !== update.version &&
version !== fakeFilterVersion(update.rules)
) {
return Promise.reject(
new BusinessException('MAIL_012', {
context: { reason: 'e2e-filter-rules-changed' },
}),
);
}
if (
update.rules.some((rule) =>
rule.conditions.some(
({ value }) => value === ENGINE_TOO_LARGE_FILTER_VALUE,
),
)
) {
return Promise.reject(
new BusinessException('MAIL_013', {
context: { reason: 'e2e-filter-script-too-large' },
}),
);
}
mailScripts.set(user, {
rules: update.rules,
vacation: current?.vacation ?? NEVER_CONFIGURED_VACATION,
});
return Promise.resolve({
rules: update.rules,
version: fakeFilterVersion(update.rules),
isActive: true,
});
},
// 7단계 — 차례 · 판정 · 이어 할 자리는 jmap-filter-run.spec.ts 가 본다. 여기서는 받은 값을 기록하고 cursor 로 이어 하기만 흉내 낸다
runMailFilter: async (
user: string,
request: MailFilterRunRequest,
): Promise<MailFilterRunOutcome> => {
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<MessageSource> => {
if (id === GONE_MESSAGE) {
return Promise.reject(
new BusinessException('RES_001', {
context: { reason: 'e2e-message-not-found' },
}),
);
}
return Promise.resolve({
stream: Readable.from([Buffer.from(RAW_MESSAGE, 'utf8')]),
subject: RAW_MESSAGE_SUBJECT,
size: Buffer.byteLength(RAW_MESSAGE, 'utf8'),
});
},
updateMessage: (
user: string,
id: string,
update: MessageUpdate,
): Promise<MessageSummary> => {
singleUpdates.push({ user, id, update });
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<MessageDetailSource> => {
// 11단계 — 보낸 사본 (대용량 첨부 덩이 알아보기 · 우리 웹메일 받기)
const sent = sentCopy(user, id);
if (sent) return Promise.resolve(sentDetailOf(id, sent));
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: `<p>안녕하세요</p><img src="${PUBLIC_IMAGE_URL}" alt="로고"><img src="${INTERNAL_IMAGE_URL}">`,
isRawHtmlBodyTruncated: false,
textBody: '안녕하세요',
isTextBodyTruncated: false,
attachments: [],
inlineParts: [],
isInJunkMailbox: false,
});
},
// 11단계 — 보낸 링크 목록의 메일 제목 (보낸 사본만 — 없는 메일은 결과에 들지 않는다)
getMessageSubjects: (
user: string,
ids: readonly string[],
): Promise<Map<string, string>> =>
Promise.resolve(
new Map(
ids.flatMap((id): [string, string][] => {
const sent = sentCopy(user, id);
return sent ? [[id, sent.message.subject]] : [];
}),
),
),
// 9단계 — 차단 목록 (저장 자리 · Sieve · 판은 jmap-filter-script.spec.ts 가 본다)
getMailBlocks: (user: string): Promise<MailBlockList> =>
Promise.resolve({
senders: [...(mailBlocks.get(user) ?? [])],
isActive: true,
}),
addMailBlock: (
user: string,
sender: BlockedSender,
): Promise<MailBlockAdded> => {
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<void> => {
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<void> => {
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<MessageSummary[]> =>
Promise.resolve(scheduledMessages.get(user) ?? []),
cancelPendingSend: (user: string, id: string): Promise<void> => {
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)
// 11단계 — 파일은 임시 폴더에, 공개 링크는 고정한 공개 호스트로 (env 와 무관하게)
.overrideProvider(FILE_STORAGE_SETTINGS)
.useValue(testFileStorageSettings(fileDir))
.overrideProvider(FILE_SHARING_SETTINGS)
.useValue(TEST_FILE_SHARING_SETTINGS)
// 12단계 — 엔진의 2단계 인증 · 앱 비밀번호는 대역으로(어댑터는 jmap-account-security.provider.spec.ts),
// 웹훅 서명 키 · 앱 네트워크 대역은 env 와 무관하게 고정한다
.overrideProvider(ACCOUNT_SECURITY_PROVIDER)
.useValue(fakeAccountSecurityProvider)
.overrideProvider(ACCOUNT_SECURITY_SETTINGS)
.useValue(TEST_ACCOUNT_SECURITY_SETTINGS)
// 14단계 — 검색 색인의 엔진 읽기는 대역으로
.overrideProvider(MAIL_INDEX_SOURCE)
.useValue(fakeIndexSource)
.compile();
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
dataSource = moduleFixture.get(DataSource);
await dataSource.runMigrations();
// main.ts 와 **같은 함수**로 어댑터를 만들고(대소문자를 가리는 라우팅) 전역 설정을 적용한다 — 실제 응답과 같아진다
app = moduleFixture.createNestApplication<NestExpressApplication>(
createHttpAdapter(),
{ bodyParser: false },
);
// 요청 추적 ID 도 configureApp 이 맨 앞에 붙인다 (본문 파서 오류에도 traceId)
configureApp(app);
await app.init();
// 14단계 — 검색 색인의 5분 맞추기 · 1분 채우기는 돌리지 않는다 — 테스트가 직접 부른다.
// 거꾸로 같은 DB 를 쓰는 개발 서버(개발 PC 에서만 — CI 에는 없다)의 cron 은 사내 도메인(arasystem.test) 주소만 맞추고 채우므로
// 이 시험의 줄(e2e.arasystem.test)을 건드리지 않는다 (MailSearchSyncService.isIndexedAddress)
const scheduler = app.get(SchedulerRegistry);
for (const name of ['mail-search-sync', 'mail-search-build']) {
void scheduler.getCronJob(name).stop();
}
server = app.getHttpServer();
jwt = app.get(JwtService, { strict: false });
authSettings = app.get<AuthSettings>(AUTH_SETTINGS, { strict: false });
}, APP_SETUP_TIMEOUT_MS);
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',
// 11단계 — 링크(파일 묶음은 함께 지워진다) · 파일(조각 기록도 함께)
'DELETE FROM share_links WHERE owner_email LIKE $1',
'DELETE FROM stored_files WHERE owner_email LIKE $1',
// 12단계 — 로그인 기록 · 앱 비밀번호 기록 · 2단계 인증을 켠 때
'DELETE FROM login_events WHERE user_email LIKE $1',
'DELETE FROM app_password_usages WHERE user_email LIKE $1',
'DELETE FROM two_factor_settings WHERE user_email LIKE $1',
// 14단계 — 검색 색인 (로그인하면 상태 줄이 생긴다)
'DELETE FROM mail_search_messages WHERE user_email LIKE $1',
'DELETE FROM mail_search_states WHERE user_email LIKE $1',
]) {
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
}
await app.close();
if (fileDir) rmSync(fileDir, { recursive: true, force: true });
});
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, ...UNRESTRICTED },
});
// 토큰은 본문에 없다
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, ...UNRESTRICTED });
});
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, ...UNRESTRICTED });
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: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
}).expect(201);
const saved = dataOf<SavedDraft>(res);
expect(saved).toEqual({
id: expect.stringMatching(/^draft\d+$/) as string,
attachments: [],
updatedAt: expect.any(String) as string,
});
expect(drafts.get(saved.id)?.content).toMatchObject({
to: [],
htmlBody: '<p>쓰는 중</p>',
});
});
it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => {
const first = dataOf<SavedDraft>(
await save('post', '/api/drafts', {}).expect(201),
);
const second = dataOf<SavedDraft>(
await save('put', `/api/drafts/${first.id}`, {
to: [DOYUN],
subject: '고친 제목',
bodyHtml:
'<p><strong>굵게</strong><img src="https://x.test/p.gif"></p>',
}).expect(200),
);
expect(second.id).not.toBe(first.id);
expect(drafts.has(first.id)).toBe(false);
const res = await request(server)
.get(`/api/drafts/${second.id}`)
.set('Cookie', cookieOf(EMPLOYEE.email))
.expect(200);
expect(dataOf<unknown>(res)).toEqual({
id: second.id,
to: [DOYUN],
cc: [],
bcc: [],
subject: '고친 제목',
body: '본문',
bodyHtml: '<p><strong>굵게</strong></p>',
attachments: [],
inReplyTo: null,
updatedAt: second.updatedAt,
// 11단계 — 대용량 파일이 없으면 빈 배열 (계약 4절 "임시 저장")
largeFiles: [],
});
});
it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => {
const changed = await save(
'put',
`/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`,
{},
).expect(409);
expect(body(changed).error?.code).toBe('MAIL_008');
expect(body(changed).error?.traceId).toBe(
changed.headers['x-request-id'],
);
for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) {
const res = await save('put', path, {}).expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => {
const mine = dataOf<SavedDraft>(
await save('post', '/api/drafts', {}).expect(201),
);
const res = await request(server)
.get(`/api/drafts/${mine.id}`)
.set('Cookie', cookieOf('other.person@e2e.arasystem.test'))
.expect(404);
expect(body(res).error?.code).toBe('RES_001');
});
it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => {
const recipients = (count: number) =>
Array.from({ length: count }, (_, index) => ({
name: null,
email: `r${index}@e2e.arasystem.test`,
}));
const cases: [object, number, string][] = [
[{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'],
[{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'],
[{ draftId: 'eaaaaab' }, 400, 'VAL_001'],
];
const before = drafts.size;
for (const [payload, status, code] of cases) {
const res = await save('post', '/api/drafts', payload).expect(status);
expect(body(res).error?.code).toBe(code);
}
expect(drafts.size).toBe(before);
});
it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
for (const [method, path] of [
['post', '/api/drafts'],
['put', '/api/drafts/eaaaaab'],
] as const) {
const parsed = await request(server)
[method](path)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
.expect(401);
expect(body(parsed).error?.code).toBe('AUTH_001');
const tooLarge = await request(server)
[method](path)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
.expect(413);
expect(body(tooLarge).error?.code).toBe('FILE_002');
}
});
it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => {
const draft = dataOf<SavedDraft>(
await save('post', '/api/drafts', { to: [DOYUN] }).expect(201),
);
const send = (draftId: string) =>
request(server)
.post('/api/messages')
.set('Cookie', cookieOf(EMPLOYEE.email))
.send({ to: [DOYUN], subject: '제목', body: '본문', draftId });
const sentBefore = sentMessages.length;
const malformed = await send('../draft1').expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(sentBefore);
await send(draft.id).expect(201);
expect(sentMessages).toHaveLength(sentBefore + 1);
// 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다)
expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId');
expect(deletedDrafts).toContain(draft.id);
expect(drafts.has(draft.id)).toBe(false);
});
});
describe('서명 (/api/mail-signature)', () => {
const imageUrl = (id: string) => `/api/mail-signature/images/${id}`;
const uploadAs = (
email: string,
data: Buffer,
filename = 'logo.png',
contentType = 'image/png',
) =>
request(server)
.post('/api/mail-signature/images')
.set('Cookie', cookieOf(email))
.attach('file', data, { filename, contentType });
const uploaded = async (email: string, data = pngBytes(480, 120)) =>
dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id;
const saveSignature = (email: string, payload: object) =>
request(server)
.put('/api/mail-signature')
.set('Cookie', cookieOf(email))
.send({ isForNew: true, isForReply: true, ...payload });
it('저장한 적 없으면 기본값', async () => {
const res = await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf('sign.default@e2e.arasystem.test'))
.expect(200);
expect(dataOf<unknown>(res)).toEqual({
bodyHtml: null,
body: null,
isForNew: true,
isForReply: true,
images: [],
updatedAt: null,
});
});
it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => {
const owner = 'sign.owner@e2e.arasystem.test';
const data = pngBytes(480, 120, 2_048);
const image = dataOf<Record<string, unknown>>(
await uploadAs(owner, data, '회사 로고.png').expect(201),
);
expect(image).toEqual({
id: expect.stringMatching(UUID) as string,
type: 'image/png',
size: 2_048,
width: 480,
height: 120,
});
const id = String(image.id);
const res = await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(owner))
.expect(200);
expect(res.headers['content-type']).toBe('image/png');
expect(res.headers['x-content-type-options']).toBe('nosniff');
expect(res.headers['cache-control']).toBe('private, max-age=86400');
expect(res.headers['content-disposition']).toBe(
'inline; filename="signature.png"',
);
expect(res.body).toEqual(data);
for (const [cookie, path] of [
[cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)],
[cookieOf(owner), imageUrl('not-a-uuid')],
[cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')],
]) {
const denied = await request(server)
.get(path)
.set('Cookie', cookie)
.expect(404);
expect(body(denied).error?.code).toBe('RES_001');
}
});
it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => {
const user = 'sign.reject@e2e.arasystem.test';
for (const [data, filename, contentType] of [
// 이름·MIME 은 PNG 인데 내용은 HTML
[
Buffer.from('<html><script>alert(1)</script></html>'),
'logo.png',
'image/png',
],
// 이름과 MIME 이 다르다
[pngBytes(10, 10), 'logo.png', 'image/gif'],
[pngBytes(10, 10), 'logo.webp', 'image/webp'],
[Buffer.from('<svg onload="x()"/>'), 'logo.svg', 'image/svg+xml'],
] as const) {
const res = await uploadAs(user, data, filename, contentType).expect(
400,
);
expect(body(res).error?.code).toBe('FILE_001');
}
const tooLarge = await uploadAs(
user,
pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1),
).expect(413);
expect(body(tooLarge).error?.code).toBe('FILE_002');
const missing = await request(server)
.post('/api/mail-signature/images')
.set('Cookie', cookieOf(user))
.expect(400);
expect(body(missing).error?.code).toBe('VAL_001');
// 하나도 저장되지 않았다
const rows: { id: string }[] = await dataSource.query(
'SELECT id FROM mail_signature_images WHERE user_email = $1',
[user],
);
expect(rows).toEqual([]);
});
it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => {
const user = 'sign.many@e2e.arasystem.test';
const ids: string[] = [];
for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) {
ids.push(await uploaded(user));
}
const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400);
expect(body(overflow).error?.code).toBe('VAL_001');
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
const tooMany = await saveSignature(user, {
bodyHtml: ids
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1)
.map(img)
.join(''),
}).expect(400);
expect(body(tooMany).error?.code).toBe('VAL_001');
const fits = await saveSignature(user, {
bodyHtml: ids
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)
.map(img)
.join(''),
}).expect(200);
expect(
dataOf<{ images: { id: string }[] }>(fits).images.map(
(image) => image.id,
),
).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE));
});
it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => {
const user = 'sign.save@e2e.arasystem.test';
const [a, b] = [await uploaded(user), await uploaded(user)];
const first = await saveSignature(user, {
bodyHtml: `<p onclick="x()">김아라</p><img src="${imageUrl(a)}" width="144" alt=""><img src="${imageUrl(b)}" width="96"><img src="${imageUrl('11111111-1111-4111-8111-111111111111')}">`,
body: '김아라 · 아라시스템',
isForReply: false,
}).expect(200);
expect(dataOf<Record<string, unknown>>(first)).toMatchObject({
bodyHtml: `<p>김아라</p><img src="${imageUrl(a)}" width="144" alt="" /><img src="${imageUrl(b)}" width="96" />`,
body: '김아라 · 아라시스템',
isForNew: true,
isForReply: false,
images: [
expect.objectContaining({ id: a }) as unknown,
expect.objectContaining({ id: b }) as unknown,
],
updatedAt: expect.any(String) as string,
});
// b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다
const c = await uploaded(user);
const second = await saveSignature(user, {
bodyHtml: `<img src="${imageUrl(a)}" width="144">`,
body: null,
}).expect(200);
expect(
dataOf<{ images: { id: string }[] }>(second).images.map(
(image) => image.id,
),
).toEqual([a]);
const statusOf = async (id: string) =>
(
await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user))
).status;
expect(await statusOf(a)).toBe(200);
expect(await statusOf(b)).toBe(404);
expect(await statusOf(c)).toBe(200);
// 지운 그림은 파일도 없다
expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false);
const current = await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf(user))
.expect(200);
expect(dataOf<unknown>(current)).toEqual(dataOf<unknown>(second));
});
it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => {
const user = 'sign.limits@e2e.arasystem.test';
for (const payload of [
{ body: '가'.repeat(2_001) },
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
]) {
const res = await saveSignature(user, payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => {
const user = 'sign.body@e2e.arasystem.test';
const ids: string[] = [];
for (
let index = 0;
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
index += 1
) {
ids.push(await uploaded(user, pngBytes(40, 10)));
}
const bodyHtml = `<p>본문</p>${ids.map((id) => `<img src="${imageUrl(id)}" width="96">`).join('')}`;
const sentBefore = sentMessages.length;
const draftsBefore = drafts.size;
const send = await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(user))
.send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml })
.expect(400);
expect(body(send).error?.code).toBe('VAL_001');
const draft = await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(user))
.send({ to: [], subject: '그림 넷', body: '본문', bodyHtml })
.expect(400);
expect(body(draft).error?.code).toBe('VAL_001');
expect(sentMessages).toHaveLength(sentBefore);
expect(drafts.size).toBe(draftsBefore);
});
it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => {
const user = 'sign.race@e2e.arasystem.test';
const [a, b] = [await uploaded(user), await uploaded(user)];
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect(
200,
);
// 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다
const [first, second] = await Promise.all([
saveSignature(user, { bodyHtml: `<p>탭 1</p>${img(a)}` }),
saveSignature(user, { bodyHtml: `<p>탭 2</p>${img(b)}` }),
]);
expect([first.status, second.status]).toEqual([200, 200]);
const final = dataOf<{
bodyHtml: string | null;
images: { id: string }[];
}>(
await request(server)
.get('/api/mail-signature')
.set('Cookie', cookieOf(user))
.expect(200),
);
const referenced = [
...(final.bodyHtml ?? '').matchAll(
/\/api\/mail-signature\/images\/([0-9a-f-]{36})/g,
),
].map((match) => match[1]);
expect(final.images.map((image) => image.id)).toEqual(referenced);
// 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다
for (const id of [a, b]) {
const res = await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user));
expect(res.status).toBe(referenced.includes(id) ? 200 : 404);
}
});
it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => {
const user = 'sign.cleanup@e2e.arasystem.test';
const [attached, loose] = [await uploaded(user), await uploaded(user)];
await saveSignature(user, {
bodyHtml: `<img src="${imageUrl(attached)}" width="96">`,
}).expect(200);
// 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다
const repository = app.get(MailSignatureRepository, { strict: false });
const deleted = await repository.deleteUnattachedImages([
attached,
loose,
]);
// 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게)
const left: { id: string }[] = await dataSource.query(
'SELECT id FROM mail_signature_images WHERE id = ANY($1)',
[[attached, loose]],
);
for (const id of [attached, loose]) {
const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`);
if (!left.some((row) => row.id === id) && existsSync(stored)) {
unlinkSync(stored);
}
}
expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]);
const statusOf = async (id: string) =>
(
await request(server)
.get(imageUrl(id))
.set('Cookie', cookieOf(user))
).status;
expect(await statusOf(attached)).toBe(200);
expect(await statusOf(loose)).toBe(404);
});
it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => {
const user = 'sign.send@e2e.arasystem.test';
const data = pngBytes(200, 50, 1_000);
const id = await uploaded(user, data);
const html = `<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고">`;
const cid = `sig-${id}@e2e.arasystem.test`;
await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(user))
.send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html })
.expect(201);
const sent = sentMessages[sentMessages.length - 1];
expect(sent.user).toBe(user);
expect(sent.message.htmlBody).toBe(
`<p>본문</p><img src="cid:${cid}" width="144" alt="로고" />`,
);
expect(sent.message.inlineImages).toEqual([
{ cid, type: 'image/png', name: 'signature-1.png', data },
]);
const saved = dataOf<SavedDraft>(
await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(user))
.send({ to: [], subject: '', body: '본문', bodyHtml: html })
.expect(201),
);
expect(drafts.get(saved.id)?.content.htmlBody).toBe(
`<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고" />`,
);
});
});
describe('받는 사람 추천 (/api/recipient-suggestions)', () => {
const ME = 'suggest.me@e2e.arasystem.test';
const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test';
const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test';
const suggest = (params: Record<string, string | number>) =>
request(server)
.get('/api/recipient-suggestions')
.query(params)
.set('Cookie', cookieOf(ME));
interface SuggestionPage {
items: {
name: string | null;
email: string;
source: string;
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<SuggestionPage>(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<SuggestionPage>(
await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200),
);
expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]);
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
const cursor = first.pageInfo.nextCursor ?? '';
const second = dataOf<SuggestionPage>(
await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200),
);
expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]);
expect(second.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 2,
});
const rejected: Record<string, string | number>[] = [
{ q: 'partner', cursor },
{ cursor: 'not-a-cursor' },
{ q: 'x'.repeat(101) },
{ limit: 101 },
];
for (const params of rejected) {
const res = await suggest(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => {
const now = new Date().toISOString();
correspondents.set(ME, [
{
name: '새 거래처',
email: 'new@client.e2e.arasystem.test',
at: now,
},
]);
const emailsOf = async () =>
dataOf<SuggestionPage>(
await suggest({ q: 'client.e2e', limit: 100 }).expect(200),
).items.map((item) => item.email);
expect(await emailsOf()).toEqual([]);
await request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '제목', body: '본문' })
.expect(201);
expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']);
});
});
});
/**
* 3단계 — 새 메일 확인 · 검색 · 여러 통 한꺼번에 (계약 2절).
* 엔진 쪽 판단(조사를 붙인 검색 조건 · 휴지통·스팸메일함 빼기 · 조건부 영구 삭제)은 jmap-mail.provider.spec.ts 가 본다 —
* 여기서는 라우트 · 검증(VAL_001) · 상태 코드 · 응답 형식 · 헤더를 본다.
*/
describe('3단계 — 새 메일 확인 · 검색 · 여러 통', () => {
const ME = 'bulk.me@e2e.arasystem.test';
describe('새 메일 확인 (GET /api/mail-state)', () => {
it('불투명 상태 값을 표준 래퍼로 — Cache-Control: private, no-store', async () => {
const res = await request(server)
.get('/api/mail-state')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(body(res)).toEqual({
success: true,
data: { state: MAIL_STATE },
});
expect(res.headers['cache-control']).toBe('private, no-store');
});
});
describe('검색 (GET /api/messages 의 q)', () => {
const list = (params: Record<string, string | number>) =>
request(server)
.get('/api/messages')
.query(params)
.set('Cookie', cookieOf(ME));
it('q 만 있으면 메일함 없이(모든 메일함 검색) 앞뒤 공백을 뺀 검색어로 묻는다 — 목록 응답 형식 그대로', async () => {
const before = listQueries.length;
const res = await list({ q: ' 회의 일정 ', limit: 20 }).expect(200);
expect(dataOf<unknown>(res)).toEqual({
items: [],
pageInfo: { hasNext: false, nextCursor: null, total: 0 },
});
expect(listQueries.slice(before)).toEqual([
{
user: ME,
query: {
mailboxId: undefined,
limit: 20,
cursor: undefined,
text: '회의 일정',
},
},
]);
});
it('메일함과 검색어를 함께 주면 그 메일함에서 찾는다 · 메일함만 주면 지금처럼 목록', async () => {
const before = listQueries.length;
await list({ mailboxId: 'a', q: '"회의를 잡겠습니다"' }).expect(200);
await list({ mailboxId: 'a' }).expect(200);
expect(
listQueries
.slice(before)
.map(({ query }) => [query.mailboxId, query.text]),
).toEqual([
['a', '"회의를 잡겠습니다"'],
['a', undefined],
]);
});
it(`메일함도 검색어도 없음 · 공백뿐인 검색어 · ${MAX_SEARCH_LENGTH}자 초과 · 낱말 ${MAX_SEARCH_WORDS}개 초과 · 같은 이름 두 번은 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
const before = listQueries.length;
const words = (count: number) =>
Array.from({ length: count }, (_, index) => `w${index}`).join(' ');
const rejected: Record<string, string>[] = [
{},
{ q: ' ' },
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
{ q: words(MAX_SEARCH_WORDS + 1) },
{ mailboxId: '../a', q: '회의' },
];
for (const params of rejected) {
const res = await list(params).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
const twice = await request(server)
.get('/api/messages?q=a&q=b')
.set('Cookie', cookieOf(ME))
.expect(400);
expect(body(twice).error?.code).toBe('VAL_001');
// 한도 안은 묻는다 — 앞뒤 공백은 길이에 넣지 않는다
await list({
q: ` ${'가'.repeat(MAX_SEARCH_LENGTH)} `,
}).expect(200);
await list({ q: words(MAX_SEARCH_WORDS) }).expect(200);
expect(listQueries.length - before).toBe(2);
});
});
describe('여러 통 바꾸기 (PATCH /api/messages)', () => {
const patch = (payload: object) =>
request(server)
.patch('/api/messages')
.set('Cookie', cookieOf(ME))
.send(payload);
it('200 { updatedIds, failedIds } — 바꾸지 못한 메일은 failedIds, 한 통 PATCH 경로와 따로 논다', async () => {
const res = await patch({
ids: ['eaaaaab', GONE_MESSAGE],
isRead: true,
}).expect(200);
expect(body(res)).toEqual({
success: true,
data: { updatedIds: ['eaaaaab'], failedIds: [GONE_MESSAGE] },
});
expect(bulkUpdates.at(-1)).toEqual({
user: ME,
ids: ['eaaaaab', GONE_MESSAGE],
update: { isRead: true, isFlagged: undefined, mailboxId: undefined },
});
});
it('없는 메일함으로 옮기면 404 RES_001', async () => {
const res = await patch({
ids: ['eaaaaab'],
mailboxId: MISSING_MAILBOX,
}).expect(404);
expect(body(res).error?.code).toBe('RES_001');
});
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 바꿀 것 없음 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다`, async () => {
const before = bulkUpdates.length;
const many = Array.from(
{ length: MAX_BULK_MESSAGE_IDS + 1 },
(_, index) => `m${index}`,
);
for (const payload of [
{ isRead: true },
{ ids: [], isRead: true },
{ ids: many, isRead: true },
{ ids: ['eaaaaab', 'eaaaaab'], isRead: true },
{ ids: ['../eaaaaab'], isRead: true },
{ ids: ['eaaaaab'] },
{ ids: ['eaaaaab'], isRead: null },
{ ids: ['eaaaaab'], isRead: true, isDeleted: true },
]) {
const res = await patch(payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(bulkUpdates).toHaveLength(before);
});
});
describe('휴지통에서 영구 삭제 (POST /api/message-deletions)', () => {
const remove = (payload: object) =>
request(server)
.post('/api/message-deletions')
.set('Cookie', cookieOf(ME))
.send(payload);
it('201 이 아니라 200 { deletedIds, skippedIds }', async () => {
const res = await remove({ ids: ['eaaaaab', GONE_MESSAGE] }).expect(
200,
);
expect(body(res)).toEqual({
success: true,
data: { deletedIds: ['eaaaaab'], skippedIds: [GONE_MESSAGE] },
});
expect(trashDeletions.at(-1)).toEqual({
user: ME,
ids: ['eaaaaab', GONE_MESSAGE],
});
});
it(`ids 없음 · 빈 ids · ${MAX_BULK_MESSAGE_IDS}개 초과 · 같은 id 두 번 · 형식이 틀린 id · 모르는 필드는 400 VAL_001 — 지우지 않는다`, async () => {
const before = trashDeletions.length;
for (const payload of [
{},
{ ids: [] },
{
ids: Array.from(
{ length: MAX_BULK_MESSAGE_IDS + 1 },
(_, index) => `m${index}`,
),
},
{ ids: ['eaaaaab', 'eaaaaab'] },
{ ids: ['eaaaaab', 'a b'] },
{ ids: ['eaaaaab'], permanent: true },
]) {
const res = await remove(payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(trashDeletions).toHaveLength(before);
});
});
});
describe('4단계 — 비밀번호 · 메일함 관리 · 부재중 자동 응답 · 스팸 · 원문', () => {
describe('비밀번호 바꾸기 (PUT /api/auth/password)', () => {
const NEW_PASSWORD = 'new-password-for-e2e';
/** 로그인한 두 쿠키로 보낸다 — 리프레시 쿠키(Path=/api/auth)가 이 세션을 알려 준다 */
const changeWith = (
session: { access: string; refresh: string },
payload: object,
ip: string,
) =>
request(server)
.put('/api/auth/password')
.set('X-Forwarded-For', ip)
.set(
'Cookie',
`access_token=${session.access}; refresh_token=${session.refresh}`,
)
.send(payload);
/** 액세스 쿠키만으로 보낸다 (형식 검사 · 한도 확인용) */
const changeAs = (email: string, payload: object, ip: string) =>
request(server)
.put('/api/auth/password')
.set('X-Forwarded-For', ip)
.set('Cookie', cookieOf(email))
.send(payload);
/** 리프레시 토큰이 가리키는 세션의 끝난 사유 */
const revokedReasonOf = async (refreshToken: string) => {
const { sid } = jwt.decode<{ sid: string }>(refreshToken);
const rows: { revoked_reason: string | null }[] =
await dataSource.query(
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
[sid],
);
return rows[0]?.revoked_reason;
};
it('바뀌면 200 data null — 이 세션만 남기고 그 직원의 다른 세션을 끊는다 (쿠키는 그대로)', async () => {
const email = 'pw.change@e2e.arasystem.test';
const mine = await login(email);
const other = await login(email);
const res = await changeWith(
mine,
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
'198.51.100.200',
).expect(200);
expect(body(res)).toEqual({ success: true, data: null });
expect(res.headers['set-cookie']).toBeUndefined();
expect(passwordChanges.at(-1)).toEqual({
email,
newPassword: NEW_PASSWORD,
});
expect(await revokedReasonOf(other.refresh)).toBe('password-changed');
expect(await revokedReasonOf(mine.refresh)).toBeNull();
await refreshWith(mine.refresh).expect(200);
const cut = await refreshWith(other.refresh).expect(401);
expect(body(cut).error?.code).toBe('AUTH_001');
});
it('지금 비밀번호가 틀리면 401 이 아니라 400 AUTH_005 — 세션은 그대로 · 엔진 규칙에 어긋난 새 비밀번호는 400 VAL_001', async () => {
const mine = await login('pw.wrong@e2e.arasystem.test');
const wrong = await changeWith(
mine,
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
'198.51.100.201',
).expect(400);
expect(body(wrong).error?.code).toBe('AUTH_005');
const rejected = await changeWith(
mine,
{
currentPassword: CORRECT_PASSWORD,
newPassword: ENGINE_REJECTED_PASSWORD,
},
'198.51.100.201',
).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
expect(await revokedReasonOf(mine.refresh)).toBeNull();
await refreshWith(mine.refresh).expect(200);
});
it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면 400 AUTH_005 — 실패로 세지 않아 10번을 넘겨도 429 가 아니고, 세션은 그대로', async () => {
const email = 'pw.refused@e2e.arasystem.test';
const mine = await login(email);
const other = await login(email);
for (let i = 0; i < 12; i += 1) {
const res = await changeWith(
mine,
{
currentPassword: CORRECT_PASSWORD,
newPassword: ENGINE_REFUSED_PASSWORD,
},
'198.51.100.240',
).expect(400);
expect(body(res).error?.code).toBe('AUTH_005');
}
expect(await revokedReasonOf(other.refresh)).toBeNull();
await refreshWith(other.refresh).expect(200);
});
it('결과를 알 수 없게 끝나면 503 MAIL_001 — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 둔다', async () => {
const email = 'pw.unknown@e2e.arasystem.test';
const mine = await login(email);
const other = await login(email);
const res = await changeWith(
mine,
{
currentPassword: CORRECT_PASSWORD,
newPassword: ENGINE_UNKNOWN_PASSWORD,
},
'198.51.100.241',
).expect(503);
expect(body(res).error?.code).toBe('MAIL_001');
expect(await revokedReasonOf(other.refresh)).toBe(
'password-change-uncertain',
);
expect(await revokedReasonOf(mine.refresh)).toBeNull();
await refreshWith(mine.refresh).expect(200);
const cut = await refreshWith(other.refresh).expect(401);
expect(body(cut).error?.code).toBe('AUTH_001');
});
it('형식 오류는 400 VAL_001 — 7자 · 129자 · 지금과 같음 · 빠진 필드 · 모르는 필드, 엔진에 묻지 않는다', async () => {
const before = passwordChanges.length;
for (const payload of [
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(7) },
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(129) },
{ currentPassword: CORRECT_PASSWORD, newPassword: CORRECT_PASSWORD },
{ newPassword: NEW_PASSWORD },
{ currentPassword: '', newPassword: NEW_PASSWORD },
{
currentPassword: CORRECT_PASSWORD,
newPassword: NEW_PASSWORD,
confirmPassword: NEW_PASSWORD,
},
]) {
const res = await changeAs(
'pw.format@e2e.arasystem.test',
payload,
'198.51.100.202',
).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(passwordChanges).toHaveLength(before);
});
it('틀린 지금 비밀번호는 로그인 실패와 같은 한도 — 같은 주소로 10번이 넘으면 맞는 비밀번호도 429 REQ_001, 그 주소의 로그인도 막힌다', async () => {
const email = 'pw.locked@e2e.arasystem.test';
for (let i = 0; i < 10; i += 1) {
const res = await changeAs(
email,
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
`198.51.100.${210 + i}`,
).expect(400);
expect(body(res).error?.code).toBe('AUTH_005');
}
const locked = await changeAs(
email,
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
'198.51.100.230',
).expect(429);
expect(body(locked).error?.code).toBe('REQ_001');
const login = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.51.100.231')
.send({ email, password: CORRECT_PASSWORD })
.expect(429);
expect(body(login).error?.code).toBe('REQ_001');
});
});
describe('메일함 관리 (/api/mailboxes)', () => {
const ME = 'box.me@e2e.arasystem.test';
const send = (
method: 'post' | 'patch' | 'delete',
path: string,
payload?: object,
) => {
const req = request(server)[method](path).set('Cookie', cookieOf(ME));
return payload === undefined ? req : req.send(payload);
};
it('만들기는 201 Mailbox — 이름은 앞뒤 공백을 빼고, 상위가 없으면 맨 위(null)로 엔진에 넘긴다', async () => {
const res = await send('post', '/api/mailboxes', {
name: ' 거래처 ',
}).expect(201);
expect(body(res)).toEqual({
success: true,
data: {
id: 'newbox',
name: '거래처',
role: null,
parentId: null,
sortOrder: 0,
totalCount: 0,
unreadCount: 0,
},
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'create',
args: [{ name: '거래처', parentId: null }],
});
await send('post', '/api/mailboxes', {
name: '하위',
parentId: 'a',
}).expect(201);
expect(mailboxCalls.at(-1)?.args).toEqual([
{ name: '하위', parentId: 'a' },
]);
});
it('이름 형식 · 상위 id 형식 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
const before = mailboxCalls.length;
for (const payload of [
{},
{ name: '' },
{ name: ' ' },
{ name: 'x'.repeat(65) },
{ name: '거래처/2026' },
{ name: '거래\t처' },
{ name: '거래처', parentId: '../a' },
{ name: '거래처', role: 'inbox' },
]) {
const res = await send('post', '/api/mailboxes', payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(mailboxCalls).toHaveLength(before);
});
it('같은 상위 안에 같은 이름은 409 MAIL_009, 없는 상위는 404 RES_001', async () => {
const taken = await send('post', '/api/mailboxes', {
name: TAKEN_MAILBOX_NAME,
}).expect(409);
expect(body(taken).error?.code).toBe('MAIL_009');
const orphan = await send('post', '/api/mailboxes', {
name: '거래처',
parentId: MISSING_MAILBOX,
}).expect(404);
expect(body(orphan).error?.code).toBe('RES_001');
});
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
const res = await send('patch', '/api/mailboxes/u1', {
name: ' 새 이름 ',
}).expect(200);
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'rename',
args: ['u1', '새 이름'],
});
const malformed = await send('patch', '/api/mailboxes/a%20b', {
name: '새 이름',
}).expect(404);
expect(body(malformed).error?.code).toBe('RES_001');
const move = await send('patch', '/api/mailboxes/u1', {
name: '새 이름',
parentId: 'a',
}).expect(400);
expect(body(move).error?.code).toBe('VAL_001');
});
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
const res = await send('delete', '/api/mailboxes/u1').expect(200);
expect(body(res)).toEqual({
success: true,
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'delete',
args: ['u1'],
});
});
it('전체 읽음은 { isRead: true } 만 받는다 — 200 { processed, remaining }', async () => {
const res = await send('patch', '/api/mailboxes/a/messages', {
isRead: true,
}).expect(200);
expect(body(res)).toEqual({
success: true,
data: { processed: 5, remaining: 0 },
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'mark-read',
args: ['a'],
});
const before = mailboxCalls.length;
for (const payload of [
{ isRead: false },
{},
{ isRead: true, isFlagged: true },
]) {
const rejected = await send(
'patch',
'/api/mailboxes/a/messages',
payload,
).expect(400);
expect(body(rejected).error?.code).toBe('VAL_001');
}
expect(mailboxCalls).toHaveLength(before);
});
it('비우기는 200 { processed, remaining }', async () => {
const res = await send('delete', '/api/mailboxes/b/messages').expect(
200,
);
expect(body(res)).toEqual({
success: true,
data: { processed: 2, remaining: 0 },
});
expect(mailboxCalls.at(-1)).toEqual({
user: ME,
action: 'empty',
args: ['b'],
});
});
/**
* 같은 메일함의 처리 중 잠금 — 대역 엔진의 SLOW_MAILBOX 비우기를 문으로 붙잡아 둔 채 다음 요청을 보낸다.
* 붙잡힌 요청이 있는 동안 supertest 가 서버를 닫지 않게 서버를 미리 열어 둔다 (로그인 동시 시도 테스트와 같은 이유)
*/
it(
'같은 직원 · 같은 메일함의 오래 걸리는 처리가 처리 중이면 종류가 달라도 엔진에 묻지 않고 429 REQ_001 — 다른 메일함은 그대로, 끝나면 다시 받는다',
async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const gate = createGate();
slowMailboxGate = gate.opened;
const before = mailboxCalls.length;
const first = send(
'delete',
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
).then((res) => res);
try {
await waitUntil(
() => mailboxCalls.length > before,
GATE_DEADLINE_MS,
);
expect(mailboxCalls.slice(before)).toEqual([
{ user: ME, action: 'empty', args: [SLOW_MAILBOX] },
]);
for (const [method, path, payload] of [
['delete', `/api/mailboxes/${SLOW_MAILBOX}/messages`, undefined],
[
'patch',
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
{ isRead: true },
],
['delete', `/api/mailboxes/${SLOW_MAILBOX}`, undefined],
] as const) {
const busy = await send(method, path, payload).expect(429);
expect(body(busy).error?.code).toBe('REQ_001');
}
expect(mailboxCalls).toHaveLength(before + 1);
// 같은 때 같은 IP 에서 다른 메일함은 된다 — 위의 429 는 요청 수 한도가 아니다
await send('patch', '/api/mailboxes/a/messages', {
isRead: true,
}).expect(200);
} finally {
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트 · 앱 종료가 멈추지 않게
gate.open();
slowMailboxGate = null;
await Promise.allSettled([first]);
if (startedServer) await closeServer(httpServer);
}
expect((await first).status).toBe(200);
await send(
'delete',
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
).expect(200);
},
GATED_TEST_TIMEOUT_MS,
);
});
describe('부재중 자동 응답 (/api/mail-vacation)', () => {
const ME = 'vacation.me@e2e.arasystem.test';
const vacation = {
isEnabled: true,
fromDate: '2026-11-02T15:00:00.000Z',
toDate: '2026-11-07T15:00:00.000Z',
subject: '부재중입니다',
textBody: '11월 7일까지 휴가입니다.',
};
const put = (payload: object) =>
request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(payload);
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 · 동작함 — 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<MailVacationState>(res);
expect(isActive).toBe(true);
const restored = await put(stored).expect(200);
expect(dataOf<unknown>(restored)).toEqual({
...NEVER_CONFIGURED_VACATION,
isActive: true,
});
// 응답 그대로(isActive 포함) 보내면 모르는 필드 — 계약 2절 "부재중 자동 응답"의 MailVacationState
const before = vacationSaves.length;
const rejected = await put(dataOf<object>(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<unknown>(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<unknown>(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: '<p>휴가</p>' },
{ ...vacation, subject: ' ' },
{ ...vacation, textBody: ' \n\t ' },
{ ...vacation, subject: '부재\r\n중' },
{ ...vacation, textBody: '휴가\u0000' },
{ ...vacation, textBody: '휴가\u001b[2J' },
{
...vacation,
fromDate: '2026-11-02T15:00:00.100Z',
toDate: '2026-11-02T15:00:00.900Z',
},
]) {
const res = await put(payload).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
expect(vacationSaves).toHaveLength(before);
});
it(`제목 ${MAX_VACATION_SUBJECT_LENGTH}자 · 내용 ${MAX_VACATION_TEXT_LENGTH}자까지는 한글로 채워도 저장한다 (엔진의 UTF-8 바이트 한도 안 — 계약 2절 "길이 한도의 근거")`, async () => {
const full = {
...vacation,
subject: '가'.repeat(MAX_VACATION_SUBJECT_LENGTH),
textBody: '가'.repeat(MAX_VACATION_TEXT_LENGTH),
};
const res = await put(full).expect(200);
expect(dataOf<unknown>(res)).toEqual({ ...full, 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<unknown>(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<unknown>(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<void>,
): Promise<void> {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
const opened: EventStreamClient[] = [];
try {
await run(httpServer, opened);
} finally {
for (const stream of opened) stream.close();
if (startedServer) await closeServer(httpServer);
}
}
it(
'200 text/event-stream — 머리글(charset · no-cache, no-transform · X-Accel-Buffering: no)과 처음 state, 그 뒤 엔진 소식을 이어서 보낸다',
async () => {
const ME = 'events.me@e2e.arasystem.test';
await withListeningServer(async (httpServer, opened) => {
const stream = await openEventStream(httpServer, cookieOf(ME));
opened.push(stream);
await waitForEvents(stream, 1);
const { headers, statusCode } = stream.response;
expect(statusCode).toBe(200);
expect(headers['content-type']).toBe(
'text/event-stream; charset=utf-8',
);
expect(headers['cache-control']).toBe('no-cache, no-transform');
expect(headers['x-accel-buffering']).toBe('no');
expect(typeof headers['x-request-id']).toBe('string');
// 표준 래퍼 없이 — 상태 값은 GET /api/mail-state 와 같다
expect(stream.received()).toBe(
`event: state\ndata: ${JSON.stringify({ state: MAIL_STATE, inboxUnread: 2 })}\n\n`,
);
const watch = liveWatches.find(({ user }) => user === ME);
expect(watch).toBeDefined();
const newMail = {
count: 1,
messages: [
{
id: 'm1',
from: {
name: '박서준',
email: 'seojun.park@e2e.arasystem.test',
},
subject: '줄\n바꿈이 있는 제목',
receivedAt: '2026-10-28T05:42:00.000Z',
},
],
};
watch?.listener.state({ state: 'c2Iz.c2Fr', inboxUnread: 3 });
watch?.listener.newMail(newMail);
await waitForEvents(stream, 3);
expect(eventsIn(stream.received()).slice(1)).toEqual([
`event: state\ndata: ${JSON.stringify({ state: 'c2Iz.c2Fr', inboxUnread: 3 })}`,
`event: new-mail\ndata: ${JSON.stringify(newMail)}`,
]);
});
},
GATED_TEST_TIMEOUT_MS,
);
it('처음 연결 때 엔진에 연결할 수 없으면 스트림을 열지 않고 503 MAIL_001 (표준 실패 JSON)', async () => {
const res = await request(server)
.get('/api/mail-events')
.set('Cookie', cookieOf(EVENTS_ENGINE_DOWN))
.expect(503);
expect(res.headers['content-type']).toMatch(/^application\/json/);
expect(body(res).success).toBe(false);
expect(body(res).error?.code).toBe('MAIL_001');
expect(body(res).error?.traceId).toBe(res.headers['x-request-id']);
});
it(
'한 직원의 연결이 5개를 넘으면 429 REQ_001 (표준 실패 JSON) — 엔진 연결은 하나를 나눠 쓴다',
async () => {
const ME = 'events.limit@e2e.arasystem.test';
await withListeningServer(async (httpServer, opened) => {
for (let i = 0; i < 5; i += 1) {
const stream = await openEventStream(httpServer, cookieOf(ME));
opened.push(stream);
await waitForEvents(stream, 1);
expect(stream.response.statusCode).toBe(200);
}
const res = await request(httpServer)
.get('/api/mail-events')
.set('Cookie', cookieOf(ME))
.expect(429);
expect(body(res).error?.code).toBe('REQ_001');
expect(liveWatches.filter(({ user }) => user === ME)).toHaveLength(1);
});
},
GATED_TEST_TIMEOUT_MS,
);
});
/**
* 받는 사람별 발송 결과 — 6단계 앞 수정 (계약 2절 "받는 사람별 결과").
* 엔진 쪽 판단(SMTP 응답 분류 · 모두 거절일 때 보낸 사본 지우기)은 대역 엔진이 흉내 낸다 (ENGINE_REJECTIONS) —
* 여기서는 HTTP 형식 · 이어 쓴 저장본 · 같은 키 잠금을 본다
*/
describe('받는 사람별 발송 결과 (POST /api/messages — 6단계 앞 수정)', () => {
const ME = 'delivery.me@e2e.arasystem.test';
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
const address = (email: string) => ({ name: null, email });
const send = (payload: object, key?: string) => {
const sending = request(server)
.post('/api/messages')
.set('Cookie', cookieOf(ME));
return (key ? sending.set('Idempotency-Key', key) : sending).send({
subject: '제목',
body: '본문',
...payload,
});
};
/** 이어 쓸 저장본을 하나 만든다 */
const savedDraft = async () =>
dataOf<SavedDraft>(
await request(server)
.post('/api/drafts')
.set('Cookie', cookieOf(ME))
.send({ to: [DOYUN], subject: '쓰는 중', body: '본문' })
.expect(201),
).id;
it('모두 받았으면 201 { id, rejectedRecipients: [] }', async () => {
const res = await send({ to: [DOYUN] }).expect(201);
expect(body(res)).toEqual({
success: true,
data: {
id: expect.stringMatching(/^sent\d+$/) as string,
rejectedRecipients: [],
// 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<unknown>(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<unknown>(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<unknown>(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<unknown>(read)).toEqual({ ...vacation, isActive: false });
const saved = await request(server)
.put('/api/mail-vacation')
.set('Cookie', cookieOf(ME))
.send(vacation)
.expect(200);
expect(dataOf<unknown>(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<string, unknown> = {}) => ({
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<Page<DirectoryEntryBody>>(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<string, string | number>) =>
dataOf<Page<DirectoryEntryBody>>(
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<Page<DirectoryEntryBody>>(
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<DirectoryEntryBody>(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<GroupBody>(
await post('/api/contact-groups', PICKER)
.send({ name: '팀' })
.expect(201),
);
const member = dataOf<ContactBody>(
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<Page<DirectoryEntryBody>>(
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<ContactBody>(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<ContactBody>(one)).toEqual(contact);
for (const q of ['ㅂㅈㅎ', '31925540', 'HANBIT', '구매']) {
const page = dataOf<Page<ContactBody>>(
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<ContactBody>(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<string, unknown> = 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<ContactBody>(
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<GroupBody>(
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<GroupBody>(renamed)).toEqual({
id: group.id,
name: '거래처',
memberCount: 0,
});
const contact = dataOf<ContactBody>(
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<unknown>(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<Page<ContactBody>>(
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<Page<GroupBody>>(
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<Page<ContactBody>>(
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<Page<ContactBody>>(
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<Page<ContactBody>>(
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<Page<ContactBody>>(
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<unknown>(first)).toEqual({
undoSendSeconds: 10,
externalImages: 'always',
});
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
200,
);
expect(dataOf<unknown>(second)).toEqual({
undoSendSeconds: 30,
externalImages: 'always',
});
const read = await request(server)
.get('/api/mail-settings')
.set('Cookie', cookieOf(ME))
.expect(200);
expect(dataOf<unknown>(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<Page<MessageSummary>>(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<Page<MessageSummary>>(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<MessageSummary>(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<MessagesPatchResult>(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 이 & 를 &amp; 로 쓰지 않는 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<ReadDetail>(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<ReadDetail>(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<ReadDetail>(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<ReadDetail>(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<BlockedSender>(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<BlockedSender>(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');
}
});
});
});
/**
* 11단계 — 대용량 첨부 (계약 4절). 파일은 이 테스트의 임시 폴더, 기록은 DB(끝나면 지운다), 검사기는 쓰지 않는다(다 올리면 unscannable).
* 공개 호스트는 포털과 다른 이름(files.e2e.arasystem.test)이라 공개 경로는 그 Host 로 부르고, 호스트 막기도 함께 본다.
* 순수 규칙(덩이 · 범위 · 기간 · 상태 · 토큰 · ClamAV · 정리)은 단위 테스트가 본다 — 여기서는 실제 HTTP 의 형식 · 머리글 · 흐름을 본다
*/
describe('11단계 — 대용량 첨부 (/api/file-uploads · /api/share-links · /api/public-links)', () => {
const SENDER = 'lf.sender@e2e.arasystem.test';
const COLLEAGUE = {
name: '이도윤',
email: 'lf.colleague@e2e.arasystem.test',
};
const STRANGER = 'lf.stranger@e2e.arasystem.test';
/** 이 묶음의 요청은 IP 를 따로 둔다 — 경로마다의 요청 수 한도(IP 당)가 앞 묶음의 요청과 섞이지 않게 */
const CLIENT_IP = '198.51.100.110';
const SUBJECT = '11월 견적 회신';
/** 쓰기 화면이 인용 바로 앞에 두는 경계 표시 — 서버가 덩이 자리로 쓰고 지운다 */
const QUOTE_HTML =
'<p>견적서를 보냅니다.</p><div data-ara-quote-start></div><p>----- 원본 메일 -----</p><p>지난 메일</p>';
const QUOTE_HTML_WITHOUT_MARKER =
'<p>견적서를 보냅니다.</p><p>----- 원본 메일 -----</p><p>지난 메일</p>';
const QUOTE_TEXT = '견적서를 보냅니다.\n\n----- 원본 메일 -----\n지난 메일';
const QUOTE_START = '견적서를 보냅니다.\n\n'.length;
const BLOCK_START = '<!--ara-large-files:v1-->';
const BLOCK_END = '<!--/ara-large-files-->';
const SHARE_URL = new RegExp(
`${E2E_SHARE_ORIGIN.replace(/\./g, '\\.')}/s/([A-Za-z0-9_-]{43})`,
);
/** 링크가 끝나는 때 — 한국 날짜의 23:59:59.999 (UTC 로 14:59:59.999) */
const KST_END_OF_DAY = /T14:59:59\.999Z$/;
interface LargeFileBody {
id: string;
name: string;
size: number;
status: string;
chunkSize: number;
chunkCount: number;
receivedChunks: number[];
firstChunkSha256: string | null;
scanStatus: string | null;
createdAt: string;
expiresAt: string;
lastModified: number | null;
}
interface SharedFileBody {
id: string;
name: string;
size: number;
status: string;
downloadCount: number;
lastDownloadedAt: string | null;
}
interface ShareLinkBody {
id: string;
sentEmailId: string | null;
subject: string | null;
sentAt: string;
expiresAt: string;
status: string;
revokedAt: string | null;
files: SharedFileBody[];
}
interface PublicLinkBody {
status: string;
sender: { name: string | null; email: string };
sentAt: string;
expiresAt: string;
files: { id: string; name: string; size: number; status: string }[];
reportAddress: string;
}
interface MessageLargeFileBody {
linkId: string;
fileId: string;
name: string;
size: number;
status: string;
expiresAt: string;
revokedAt: string | null;
url: string;
downloads: { count: number; lastDownloadedAt: string | null } | null;
}
/** 이 묶음의 IP 로 · 그 직원으로(주면) */
const client = (test: HttpTest, email?: string): HttpTest => {
const fromClient = test.set('X-Forwarded-For', CLIENT_IP);
return email ? fromClient.set('Cookie', cookieOf(email)) : fromClient;
};
/** 공개 경로 — 공개 호스트로, 쿠키 없이 */
const publicGet = (path: string) =>
client(request(server).get(path)).set('Host', E2E_SHARE_HOST);
const publicHead = (path: string) =>
client(request(server).head(path)).set('Host', E2E_SHARE_HOST);
/**
* 요청 줄의 대상을 그대로 보낸다 — supertest 는 절대 주소 꼴(`GET http://호스트/경로`) · `\` · `#` 를 그대로 보내지 못한다.
* 서버가 닫혀 있으면 열고 끝나면 닫는다. 답은 상태와 실패 코드만
*/
const rawGet = async (
target: string,
host: string,
): Promise<{ status: number; code: string | undefined }> => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
try {
const address = httpServer.address();
const port = typeof address === 'object' && address ? address.port : 0;
return await new Promise((resolve, reject) => {
const sent = httpGet(
{
host: '127.0.0.1',
port,
path: target,
headers: { host, 'x-forwarded-for': CLIENT_IP },
agent: false,
},
(response) => {
let text = '';
response.setEncoding('utf8');
response.on('data', (chunk: string) => {
text += chunk;
});
response.on('end', () =>
resolve({
status: response.statusCode ?? 0,
code: (JSON.parse(text) as ContractBody).error?.code,
}),
);
},
);
sent.on('error', reject);
});
} finally {
if (startedServer) await closeServer(httpServer);
}
};
const sha256Of = (bytes: Buffer) =>
createHash('sha256').update(bytes).digest('base64');
const digestOf = (bytes: Buffer) => `sha-256=:${sha256Of(bytes)}:`;
const createUpload = (email: string, payload: object) =>
client(request(server).post('/api/file-uploads'), email).send(payload);
const putChunk = (
email: string,
id: string,
index: number | string,
bytes: Buffer,
headers: Record<string, string> = {},
) =>
client(
request(server).put(`/api/file-uploads/${id}/chunks/${index}`),
email,
)
.set({ 'Content-Type': 'application/octet-stream', ...headers })
.send(bytes);
const complete = (email: string, id: string) =>
client(request(server).post(`/api/file-uploads/${id}/completion`), email);
const getUpload = (email: string, id: string) =>
client(request(server).get(`/api/file-uploads/${id}`), email);
const removeUpload = (email: string, id: string) =>
client(request(server).delete(`/api/file-uploads/${id}`), email);
const quotaOf = async (email: string) =>
dataOf<{ usedBytes: number }>(
await client(request(server).get('/api/file-quota'), email).expect(200),
).usedBytes;
const unsentIds = async (email: string) =>
dataOf<Page<LargeFileBody>>(
await client(
request(server).get('/api/file-uploads?limit=100'),
email,
).expect(200),
).items.map(({ id }) => id);
const shareLinksOf = async (email: string) =>
dataOf<Page<ShareLinkBody>>(
await client(
request(server).get('/api/share-links?limit=100'),
email,
).expect(200),
);
/** 한 조각 파일을 다 올린다 (만들기 → 조각 → 끝내기) */
const uploadReady = async (
email: string,
content: string,
name: string,
) => {
const bytes = Buffer.from(content, 'utf8');
const created = await createUpload(email, {
name,
size: bytes.length,
purpose: 'mail',
}).expect(201);
const { id } = dataOf<LargeFileBody>(created);
await putChunk(email, id, 0, bytes, {
'Content-Digest': digestOf(bytes),
}).expect(200);
await complete(email, id).expect(200);
return { id, bytes, name };
};
/** 인용이 있는 서식 메일로 보낸다 — 덩이는 내 글 아래 · 인용 위 */
const sendLarge = (
email: string,
largeFileIds: string[],
payload: object = {},
) =>
client(request(server).post('/api/messages'), email).send({
to: [COLLEAGUE],
subject: SUBJECT,
body: QUOTE_TEXT,
bodyHtml: QUOTE_HTML,
bodyQuoteStart: QUOTE_START,
largeFileIds,
...payload,
});
/** 보내고 — 보낸 사본(대역 엔진이 받은 메일) · 덩이의 토큰 · 보낸 링크 목록의 그 링크 */
const sendAndShare = async (
email: string,
largeFileIds: string[],
payload: object = {},
) => {
const res = await sendLarge(email, largeFileIds, payload).expect(201);
const sentId = dataOf<{ id: string }>(res).id;
const { message } = sentMessages[sentMessages.length - 1];
const token = SHARE_URL.exec(message.htmlBody ?? '')?.[1] ?? '';
const link = (await shareLinksOf(email)).items.find(
(item) => item.sentEmailId === sentId,
);
return { sentId, message, token, link };
};
it('로그인하지 않았으면 401 AUTH_001 — 공개 경로 밖은 모두', async () => {
const id = randomUUID();
for (const res of [
await client(request(server).post('/api/file-uploads')).send({
name: 'a.zip',
size: 1,
purpose: 'mail',
}),
await client(request(server).get('/api/file-uploads')),
await client(request(server).get(`/api/file-uploads/${id}`)),
await client(request(server).put(`/api/file-uploads/${id}/chunks/0`))
.set('Content-Type', 'application/octet-stream')
.send(Buffer.from('a')),
await client(
request(server).post(`/api/file-uploads/${id}/completion`),
),
await client(request(server).delete(`/api/file-uploads/${id}`)),
await client(request(server).get('/api/file-quota')),
await client(request(server).get('/api/share-links')),
await client(request(server).post(`/api/share-links/${id}/revocation`)),
await client(request(server).post('/api/share-link-lookups')).send({
urls: [`${E2E_SHARE_ORIGIN}/s/x`],
}),
await client(
request(server).get(`/api/messages/sent1/large-files/${id}`),
),
]) {
expect(res.status).toBe(401);
expect(body(res).error?.code).toBe('AUTH_001');
}
});
it('만들기 — 201 LargeFile(올리는 중 · 받은 조각 없음), 사용량은 선언한 크기로 바로 잡는다 · private, no-store', async () => {
const ME = 'lf.create@e2e.arasystem.test';
const before = await quotaOf(ME);
const res = await createUpload(ME, {
name: '현장영상.mp4',
size: 5,
purpose: 'mail',
lastModified: 1_793_241_600_000,
}).expect(201);
expect(res.headers['cache-control']).toBe('private, no-store');
const file = dataOf<LargeFileBody>(res);
expect(Object.keys(file).sort()).toEqual([
'chunkCount',
'chunkSize',
'createdAt',
'expiresAt',
'firstChunkSha256',
'id',
'lastModified',
'name',
'receivedChunks',
'scanStatus',
'size',
'status',
]);
expect(file).toMatchObject({
name: '현장영상.mp4',
size: 5,
status: 'uploading',
chunkSize: FILE_CHUNK_SIZE,
chunkCount: 1,
receivedChunks: [],
firstChunkSha256: null,
scanStatus: null,
lastModified: 1_793_241_600_000,
});
expect(file.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/,
);
const quota = await client(
request(server).get('/api/file-quota'),
ME,
).expect(200);
expect(quota.headers['cache-control']).toBe('private, no-store');
expect(dataOf<unknown>(quota)).toEqual({
usedBytes: before + 5,
limitBytes: 64 * MEGABYTE,
maxFileBytes: 2047 * MEGABYTE,
maxFilesPerMessage: 2,
chunkSize: FILE_CHUNK_SIZE,
retentionDays: 30,
});
});
it.each<[string, object, number, string]>([
[
'실행 형식',
{ name: 'setup.exe', size: 10, purpose: 'mail' },
400,
'FILE_001',
],
[
'디스크 이미지',
{ name: '설치.iso', size: 10, purpose: 'mail' },
400,
'FILE_001',
],
[
'한 파일 한도 초과',
{ name: '큰파일.zip', size: 2047 * MEGABYTE + 1, purpose: 'mail' },
413,
'FILE_002',
],
[
'직원 보관 한도 초과',
{ name: '영상.mp4', size: 65 * MEGABYTE, purpose: 'mail' },
409,
'FILE_005',
],
['빈 파일', { name: 'a.zip', size: 0, purpose: 'mail' }, 400, 'VAL_001'],
[
'정수가 아닌 크기',
{ name: 'a.zip', size: 1.5, purpose: 'mail' },
400,
'VAL_001',
],
[
'모르는 쓰임',
{ name: 'a.zip', size: 1, purpose: 'drive' },
400,
'VAL_001',
],
['빈 이름', { name: '', size: 1, purpose: 'mail' }, 400, 'VAL_001'],
[
'브라우저가 주는 형식(받지 않는 칸)',
{ name: 'a.zip', size: 1, purpose: 'mail', type: 'application/zip' },
400,
'VAL_001',
],
])(
'만들기 실패 — %s 는 %i %s (만들지 않는다)',
async (_label, payload, status, code) => {
const ME = 'lf.reject@e2e.arasystem.test';
const before = await quotaOf(ME);
const res = await createUpload(ME, payload).expect(status);
expect(body(res).error?.code).toBe(code);
expect(await quotaOf(ME)).toBe(before);
},
);
it('조각 — 다 받아 적고(맨 앞 조각의 SHA-256) 끝내면 ready(검사기를 쓰지 않으면 unscannable), 다시 보낸 같은 조각 · 다시 끝내기는 200', async () => {
const ME = 'lf.chunk@e2e.arasystem.test';
const bytes = Buffer.from('대용량 첨부 내용', 'utf8');
const { id } = dataOf<LargeFileBody>(
await createUpload(ME, {
name: '자료.zip',
size: bytes.length,
purpose: 'mail',
}).expect(201),
);
const chunk = await putChunk(ME, id, 0, bytes, {
'Content-Digest': digestOf(bytes),
}).expect(200);
expect(chunk.headers['cache-control']).toBe('private, no-store');
expect(dataOf<LargeFileBody>(chunk)).toMatchObject({
status: 'uploading',
receivedChunks: [0],
firstChunkSha256: sha256Of(bytes),
scanStatus: null,
});
// 응답을 받지 못한 재시도 — 같은 내용이면 그대로 200, 다르면 409 FILE_003 (아무것도 바꾸지 않는다)
await putChunk(ME, id, 0, bytes).expect(200);
const differs = await putChunk(
ME,
id,
0,
Buffer.alloc(bytes.length, 0x61),
).expect(409);
expect(body(differs).error?.code).toBe('FILE_003');
const done = await complete(ME, id).expect(200);
expect(dataOf<LargeFileBody>(done)).toMatchObject({
status: 'ready',
receivedChunks: [0],
scanStatus: 'unscannable',
});
await complete(ME, id).expect(200);
const closed = await putChunk(ME, id, 0, bytes).expect(409);
expect(body(closed).error?.code).toBe('FILE_003');
expect(
dataOf<LargeFileBody>(await getUpload(ME, id).expect(200)),
).toMatchObject({
id,
name: '자료.zip',
status: 'ready',
});
const page = dataOf<Page<LargeFileBody>>(
await client(request(server).get('/api/file-uploads'), ME).expect(200),
);
expect(page.items.map((item) => item.id)).toContain(id);
expect(typeof page.pageInfo.total).toBe('number');
// 남의 것 · 모양이 틀린 id 는 가리지 않고 404
for (const [email, fileId] of [
[STRANGER, id],
[ME, 'not-a-uuid'],
]) {
const res = await getUpload(email, fileId).expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
});
it('조각 실패 — 형식 · 길이 · 압축 · 번호 · 다이제스트 모양 400 VAL_001, 손상 400 FILE_004, 없는 · 남의 올리기 404 — 받은 조각은 없다', async () => {
const ME = 'lf.badchunk@e2e.arasystem.test';
const bytes = Buffer.from('0123456789');
const { id } = dataOf<LargeFileBody>(
await createUpload(ME, {
name: 'b.pdf',
size: 10,
purpose: 'mail',
}).expect(201),
);
const cases: [string, () => HttpTest, number, string][] = [
[
'JSON 본문',
() =>
client(request(server).put(`/api/file-uploads/${id}/chunks/0`), ME)
.set('Content-Type', 'application/json')
.send('{}'),
400,
'VAL_001',
],
[
'길이가 다름',
() => putChunk(ME, id, 0, bytes.subarray(0, 9)),
400,
'VAL_001',
],
[
'압축',
() => putChunk(ME, id, 0, bytes, { 'Content-Encoding': 'gzip' }),
400,
'VAL_001',
],
['범위 밖 번호', () => putChunk(ME, id, 1, bytes), 400, 'VAL_001'],
[
'숫자가 아닌 번호',
() => putChunk(ME, id, 'x', bytes),
400,
'VAL_001',
],
[
'다이제스트 모양',
() =>
putChunk(ME, id, 0, bytes, { 'Content-Digest': 'sha-256=:abc:' }),
400,
'VAL_001',
],
[
'손상(다이제스트가 다름)',
() =>
putChunk(ME, id, 0, bytes, {
'Content-Digest': digestOf(Buffer.from('9876543210')),
}),
400,
'FILE_004',
],
[
'모양이 틀린 id',
() => putChunk(ME, 'not-a-uuid', 0, bytes),
404,
'RES_001',
],
['남의 올리기', () => putChunk(STRANGER, id, 0, bytes), 404, 'RES_001'],
];
for (const [label, send, status, code] of cases) {
const res = await send();
expect({
label,
status: res.status,
code: body(res).error?.code,
}).toEqual({
label,
status,
code,
});
}
expect(
dataOf<LargeFileBody>(await getUpload(ME, id).expect(200)),
).toMatchObject({
receivedChunks: [],
firstChunkSha256: null,
});
// 같은 조각을 제대로 다시 보내면 받는다
await putChunk(ME, id, 0, bytes, {
'Content-Digest': digestOf(bytes),
}).expect(200);
});
it('조각 번호는 앞자리 0 없는 10진수만 — 숫자로 바뀌면 1 이 되는 글(01 · +1 · " 1" · 1.0 · 0x1 · 0b1 · 1e0)도 400 VAL_001 (전역 파이프의 숫자 변환을 거치지 않는다 — 리뷰 L3)', async () => {
const ME = 'lf.index@e2e.arasystem.test';
const { id } = dataOf<LargeFileBody>(
await createUpload(ME, {
name: '번호.zip',
size: FILE_CHUNK_SIZE + 3,
purpose: 'mail',
}).expect(201),
);
const last = Buffer.from('end');
for (const index of [
'01',
'+1',
'%201',
'1.0',
'0x1',
'0b1',
'1e0',
'1e1',
'+3',
'%202',
'007',
'0b11',
]) {
const res = await putChunk(ME, id, index, last);
expect({
index,
status: res.status,
code: body(res).error?.code,
}).toEqual({ index, status: 400, code: 'VAL_001' });
}
expect(
dataOf<LargeFileBody>(await getUpload(ME, id).expect(200))
.receivedChunks,
).toEqual([]);
await putChunk(ME, id, '1', last).expect(200);
});
it('끝내기 — 빠진 조각이 있으면 409 FILE_003, 그만두면(DELETE 204) 404 이고 사용량에서 바로 빠진다', async () => {
const ME = 'lf.partial@e2e.arasystem.test';
const before = await quotaOf(ME);
const size = FILE_CHUNK_SIZE + 3;
const { id } = dataOf<LargeFileBody>(
await createUpload(ME, {
name: '현장.mov',
size,
purpose: 'mail',
}).expect(201),
);
// 마지막 조각(3바이트)만 — 마지막 조각의 길이는 size − chunkSize
const last = await putChunk(ME, id, 1, Buffer.from('end')).expect(200);
expect(dataOf<LargeFileBody>(last)).toMatchObject({
chunkCount: 2,
receivedChunks: [1],
firstChunkSha256: null,
});
const incomplete = await complete(ME, id).expect(409);
expect(body(incomplete).error?.code).toBe('FILE_003');
expect(await quotaOf(ME)).toBe(before + size);
await removeUpload(ME, id).expect(204);
expect(await quotaOf(ME)).toBe(before);
const gone = await getUpload(ME, id).expect(404);
expect(body(gone).error?.code).toBe('RES_001');
const again = await removeUpload(ME, id).expect(404);
expect(body(again).error?.code).toBe('RES_001');
});
it('보내기 — 덩이를 내 글 아래 · 인용 위에(HTML · 텍스트) 넣고 경계 표시는 지운다, 링크 하나 · 보낸 링크 목록 · 실린 파일은 지울 수 없다(409 FILE_003)', async () => {
const file = await uploadReady(SENDER, 'large file body', '견적서.zip');
const { sentId, message, token, link } = await sendAndShare(SENDER, [
file.id,
]);
const html = message.htmlBody ?? '';
expect(html).not.toContain('data-ara-quote-start');
expect(html.split(BLOCK_START)).toHaveLength(2);
expect(html.indexOf('<p>견적서를 보냅니다.</p>')).toBeLessThan(
html.indexOf(BLOCK_START),
);
expect(html.indexOf(BLOCK_END)).toBeLessThan(
html.indexOf('<p>----- 원본 메일 -----</p>'),
);
expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/);
const url = `${E2E_SHARE_ORIGIN}/s/${token}`;
expect(html).toContain(url);
expect(html).toContain('견적서.zip');
// 받는 사람 · 제목은 덩이에 넣지 않는다
expect(html).not.toContain(SUBJECT);
expect(html).not.toContain(COLLEAGUE.email);
// 텍스트 — bodyQuoteStart 자리 (내 글은 그대로 앞에)
expect(message.body.startsWith('견적서를 보냅니다.\n\n')).toBe(true);
expect(message.body.indexOf(url)).toBeGreaterThan(0);
expect(message.body.indexOf(url)).toBeLessThan(
message.body.indexOf('----- 원본 메일 -----'),
);
expect(link).toMatchObject({
sentEmailId: sentId,
subject: SUBJECT,
status: 'active',
revokedAt: null,
files: [
{
id: file.id,
name: '견적서.zip',
size: file.bytes.length,
status: 'unscannable',
downloadCount: 0,
lastDownloadedAt: null,
},
],
});
expect(link?.expiresAt).toMatch(KST_END_OF_DAY);
// 살아 있는 링크에 실린 파일 — 보내지 않은 파일 목록에서 빠지고, 지우기는 409 FILE_003 (링크 끊기로)
expect(await unsentIds(SENDER)).not.toContain(file.id);
const locked = await removeUpload(SENDER, file.id).expect(409);
expect(body(locked).error?.code).toBe('FILE_003');
});
it('보내기 실패 — 올리는 중 · 없는 · 남의 파일은 400 MAIL_016, 개수 · 모양 · 겹침 · 인용 자리는 400 VAL_001 — 메일도 링크도 만들지 않는다', async () => {
const uploading = dataOf<LargeFileBody>(
await createUpload(SENDER, {
name: '올리는중.zip',
size: 10,
purpose: 'mail',
}).expect(201),
);
const strangers = await uploadReady(STRANGER, 'not yours', '남의것.zip');
const mine = await uploadReady(SENDER, 'mine', '내것.zip');
const linksBefore = (await shareLinksOf(SENDER)).pageInfo.total;
const sentBefore = sentMessages.length;
const cases: [string, string[], object, string][] = [
['올리는 중', [uploading.id], {}, 'MAIL_016'],
['없는 파일', [randomUUID()], {}, 'MAIL_016'],
['남의 파일', [strangers.id], {}, 'MAIL_016'],
[
'한 통의 개수 초과(2)',
[mine.id, uploading.id, randomUUID()],
{},
'VAL_001',
],
['UUID 모양이 아님', ['not-a-uuid'], {}, 'VAL_001'],
['겹침', [mine.id, mine.id], {}, 'VAL_001'],
[
'인용 자리가 줄의 처음이 아님',
[mine.id],
{ bodyQuoteStart: 1 },
'VAL_001',
],
[
'인용 자리가 본문 밖',
[mine.id],
{ bodyQuoteStart: QUOTE_TEXT.length + 1 },
'VAL_001',
],
];
for (const [label, ids, extra, code] of cases) {
const res = await sendLarge(SENDER, ids, extra);
expect({
label,
status: res.status,
code: body(res).error?.code,
}).toEqual({
label,
status: 400,
code,
});
}
expect(sentMessages).toHaveLength(sentBefore);
expect((await shareLinksOf(SENDER)).pageInfo.total).toBe(linksBefore);
});
it('공개 링크 정보 — 로그인 없이 보낸 사람 이름 · 주소만(제목 · 받는 사람 없음), no-store · noindex · no-referrer. 포털 호스트에서는 404 · 공개 호스트의 다른 경로도 404', async () => {
const file = await uploadReady(SENDER, 'public info', '도면.dwg');
const { token } = await sendAndShare(SENDER, [file.id]);
const res = await publicGet(`/api/public-links/${token}`).expect(200);
expect(res.headers['cache-control']).toBe('no-store');
expect(res.headers['x-robots-tag']).toBe('noindex');
expect(res.headers['referrer-policy']).toBe('no-referrer');
const info = dataOf<PublicLinkBody>(res);
expect(info).toEqual({
status: 'active',
sender: { name: '테스트 직원', email: SENDER },
sentAt: expect.any(String) as string,
expiresAt: expect.stringMatching(KST_END_OF_DAY) as string,
files: [
{
id: file.id,
name: '도면.dwg',
size: file.bytes.length,
status: 'unscannable',
},
],
reportAddress: 'postmaster@e2e.arasystem.test',
});
expect(JSON.stringify(body(res))).not.toContain(SUBJECT);
expect(JSON.stringify(body(res))).not.toContain(COLLEAGUE.email);
// 공개 호스트는 공개 경로만, 포털 호스트는 공개 경로를 열지 않는다 (경로의 대소문자는 가리지 않는다)
// 요청은 차례로 만든다 — supertest 는 요청을 만들 때 서버를 열고 끝나면 닫는다
for (const blocked of [
() => client(request(server).get(`/api/public-links/${token}`)),
() => client(request(server).get(`/API/PUBLIC-LINKS/${token}`)),
() =>
client(request(server).get('/api/users')).set('Host', E2E_SHARE_HOST),
() =>
client(request(server).get('/api/file-quota'), SENDER).set(
'Host',
E2E_SHARE_HOST,
),
]) {
const denied = await blocked().expect(404);
expect(body(denied).error?.code).toBe('RES_001');
}
// 라우터는 절대 주소 꼴(absolute-form)과 `#` 이 섞인 `\` 를 원문과 다른 경로(공개 링크)로 읽는다 — 막기도 그 경로를 본다.
// 포털에서는 404, 공개 호스트는 절대 주소 꼴을 받지 않는다 (같은 대상을 공개 호스트에 경로만으로 보내면 200)
const portalHost = '127.0.0.1';
for (const [target, host] of [
[`http://${E2E_SHARE_HOST}/api/public-links/${token}`, portalHost],
[`HTTPS://${E2E_SHARE_HOST}:443/api/public-links/${token}`, portalHost],
[`/api%2Fpublic-links/${token}`, portalHost],
[`http://${E2E_SHARE_HOST}/api/public-links/${token}`, E2E_SHARE_HOST],
[`/api/public-links/${token}/%2e%2e/%2e%2e/users`, E2E_SHARE_HOST],
]) {
expect({ target, host, ...(await rawGet(target, host)) }).toEqual({
target,
host,
status: 404,
code: 'RES_001',
});
}
// 날 `\` · `#` 이 든 대상은 그보다 앞에서(요청 대상 원문 검사 — 12단계 인프라 리뷰) 400 VAL_001 — 공개 호스트 막기까지 가지 않는다
for (const [target, host] of [
[`/api\\public-links\\${token}#a`, portalHost],
[`/api\\public-links\\${token}`, portalHost],
[`/api/public-links/${token}#a`, E2E_SHARE_HOST],
]) {
expect({ target, host, ...(await rawGet(target, host)) }).toEqual({
target,
host,
status: 400,
code: 'VAL_001',
});
}
expect(
await rawGet(`/api/public-links/${token}`, E2E_SHARE_HOST),
).toEqual({ status: 200, code: undefined });
// 모르는 토큰 · 모양이 틀린 토큰은 404
for (const unknown of [randomBytes(32).toString('base64url'), 'short']) {
const missing = await publicGet(`/api/public-links/${unknown}`).expect(
404,
);
expect(body(missing).error?.code).toBe('RES_001');
}
});
it('공개 받기 — 머리글 · HEAD(세지 않음) · 범위 206 · 416 · If-Range, 0번째 바이트부터 보낸 응답만 세고 상한(3)에 닿으면 410 FILE_007', async () => {
const file = await uploadReady(SENDER, 'abcdefghij', '사진 모음.7z');
const { token, link } = await sendAndShare(SENDER, [file.id]);
const path = `/api/public-links/${token}/files/${file.id}`;
const download = (range?: string, ifRange?: string) => {
const test = publicGet(path).responseType('blob');
if (range) void test.set('Range', range);
if (ifRange) void test.set('If-Range', ifRange);
return test;
};
const head = await publicHead(path).expect(200);
expect(head.headers).toMatchObject({
'content-type': 'application/octet-stream',
'content-length': '10',
'content-disposition': buildAttachmentDisposition('사진 모음.7z'),
'x-content-type-options': 'nosniff',
'content-security-policy': "default-src 'none'; sandbox",
'cache-control': 'private, no-store',
'referrer-policy': 'no-referrer',
'x-robots-tag': 'noindex',
'accept-ranges': 'bytes',
etag: `"${file.id}"`,
'x-accel-buffering': 'no',
});
expect(typeof head.headers['last-modified']).toBe('string');
const full = await download().expect(200); // 센다 (1)
expect((full.body as Buffer).toString()).toBe('abcdefghij');
const middle = await download('bytes=2-4').expect(206);
expect(middle.headers['content-range']).toBe('bytes 2-4/10');
expect(middle.headers['content-length']).toBe('3');
expect((middle.body as Buffer).toString()).toBe('cde');
const suffix = await download('bytes=-3').expect(206);
expect(suffix.headers['content-range']).toBe('bytes 7-9/10');
expect((suffix.body as Buffer).toString()).toBe('hij');
const beyond = await download('bytes=10-').expect(416);
expect(beyond.headers['content-range']).toBe('bytes */10');
expect(beyond.headers['content-length']).toBe('0');
await download('bytes=0-1').expect(206); // 0번째 바이트부터 — 센다 (2)
// If-Range 가 ETag 와 다르면 범위를 무시하고 전체 — 센다 (3)
const stale = await download('bytes=2-4', '"other"').expect(200);
expect((stale.body as Buffer).toString()).toBe('abcdefghij');
// 상한에 닿은 파일 — 처음부터도 이어 받기도 410
for (const range of [undefined, 'bytes=5-']) {
const test = publicGet(path);
if (range) void test.set('Range', range);
const limited = await test.expect(410);
expect(body(limited).error?.code).toBe('FILE_007');
}
const info = dataOf<PublicLinkBody>(
await publicGet(`/api/public-links/${token}`).expect(200),
);
expect(info.files[0].status).toBe('limit-reached');
const counted = (await shareLinksOf(SENDER)).items.find(
(item) => item.id === link?.id,
)?.files[0];
expect(counted).toMatchObject({
downloadCount: 3,
status: 'limit-reached',
});
expect(typeof counted?.lastDownloadedAt).toBe('string');
// 그 링크에 없는 파일은 404
const notInLink = await publicGet(
`/api/public-links/${token}/files/${randomUUID()}`,
).expect(404);
expect(body(notInLink).error?.code).toBe('RES_001');
});
it('검사 중이면 받기 409 FILE_008(정보는 scanning), 위험이면 410 FILE_007(infected) — 위험 판정 파일은 다시 보낼 수 없다(400 MAIL_016)', async () => {
const file = await uploadReady(SENDER, 'scan me', '검사.zip');
const { token } = await sendAndShare(SENDER, [file.id]);
const path = `/api/public-links/${token}/files/${file.id}`;
const setScanStatus = async (status: string): Promise<void> => {
await dataSource.query(
'UPDATE stored_files SET scan_status = $2 WHERE id = $1',
[file.id, status],
);
};
const statusInInfo = async () =>
dataOf<PublicLinkBody>(
await publicGet(`/api/public-links/${token}`).expect(200),
).files[0].status;
await setScanStatus('pending');
const scanning = await publicGet(path).expect(409);
expect(body(scanning).error?.code).toBe('FILE_008');
expect(await statusInInfo()).toBe('scanning');
await setScanStatus('infected');
const infected = await publicGet(path).expect(410);
expect(body(infected).error?.code).toBe('FILE_007');
expect(await statusInInfo()).toBe('infected');
const sentBefore = sentMessages.length;
const blocked = await sendLarge(SENDER, [file.id]).expect(400);
expect(body(blocked).error?.code).toBe('MAIL_016');
expect(sentMessages).toHaveLength(sentBefore);
});
it('임시 저장 — 올리는 중인 파일도 머리글로 두고(덩이 없음) 다시 열면 largeFiles, 지운 파일은 MissingLargeFile(deleted), 쓸 수 없는 파일은 400 MAIL_016', async () => {
const ME = 'lf.draft@e2e.arasystem.test';
const uploading = dataOf<LargeFileBody>(
await createUpload(ME, {
name: '이어올리기.mp4',
size: 10,
purpose: 'mail',
}).expect(201),
);
const ready = await uploadReady(ME, 'draft file', '초안.zip');
const save = (largeFileIds: string[]) =>
client(request(server).post('/api/drafts'), ME).send({
to: [],
subject: '쓰는 중',
body: QUOTE_TEXT,
bodyHtml: QUOTE_HTML,
largeFileIds,
});
const saved = dataOf<{ id: string }>(
await save([uploading.id, ready.id]).expect(201),
);
const content = drafts.get(saved.id)?.content;
expect(content?.largeFileIds).toEqual([uploading.id, ready.id]);
expect(content?.htmlBody).toBe(QUOTE_HTML_WITHOUT_MARKER);
const largeFilesOfDraft = async () =>
dataOf<{ largeFiles: Record<string, unknown>[] }>(
await client(
request(server).get(`/api/drafts/${saved.id}`),
ME,
).expect(200),
).largeFiles;
const opened = await largeFilesOfDraft();
expect(opened).toEqual([
expect.objectContaining({
id: uploading.id,
status: 'uploading',
receivedChunks: [],
}) as unknown,
expect.objectContaining({
id: ready.id,
status: 'ready',
scanStatus: 'unscannable',
}) as unknown,
]);
// 임시 저장에 실린 다 올린 파일은 다 올린 날(한국)부터 30일째 날 끝까지 남는다
expect(opened[1].expiresAt).toMatch(KST_END_OF_DAY);
await removeUpload(ME, ready.id).expect(204);
expect(await largeFilesOfDraft()).toEqual([
expect.objectContaining({
id: uploading.id,
status: 'uploading',
}) as unknown,
{
id: ready.id,
status: 'missing',
reason: 'deleted',
name: '초안.zip',
size: ready.bytes.length,
},
]);
for (const ids of [[randomUUID()], [ready.id]]) {
const res = await save(ids).expect(400);
expect(body(res).error?.code).toBe('MAIL_016');
}
});
it('우리 웹메일 — 보낸 사람의 메일 읽기에 largeFiles · data-ara-large-files, 보낸 사람이 받으면 세지 않고 사내 받는 사람이 받으면 센다', async () => {
const file = await uploadReady(SENDER, 'webmail bytes', '보고서.pdf');
const { sentId, token, link } = await sendAndShare(SENDER, [file.id]);
const read = async (email: string) =>
dataOf<{ htmlBody: string | null; largeFiles: MessageLargeFileBody[] }>(
await client(
request(server).get(`/api/messages/${sentId}`),
email,
).expect(200),
);
const download = (email: string) =>
client(
request(server).get(`/api/messages/${sentId}/large-files/${file.id}`),
email,
).responseType('blob');
const mine = await read(SENDER);
expect(mine.htmlBody).toContain('<div data-ara-large-files>');
expect(mine.largeFiles).toEqual([
{
linkId: link?.id,
fileId: file.id,
name: '보고서.pdf',
size: file.bytes.length,
status: 'unscannable',
expiresAt: link?.expiresAt,
revokedAt: null,
url: `${E2E_SHARE_ORIGIN}/s/${token}`,
downloads: { count: 0, lastDownloadedAt: null },
},
]);
const own = await download(SENDER).expect(200);
expect((own.body as Buffer).toString()).toBe('webmail bytes');
expect(own.headers['content-disposition']).toBe(
buildAttachmentDisposition('보고서.pdf'),
);
expect((await read(SENDER)).largeFiles[0].downloads?.count).toBe(0);
const colleague = await read(COLLEAGUE.email);
expect(colleague.largeFiles[0]).toMatchObject({
fileId: file.id,
downloads: null,
});
await download(COLLEAGUE.email).expect(200);
expect((await read(SENDER)).largeFiles[0].downloads?.count).toBe(1);
const ranged = await download(SENDER)
.set('Range', 'bytes=0-6')
.expect(206);
expect((ranged.body as Buffer).toString()).toBe('webmail');
await client(
request(server).head(`/api/messages/${sentId}/large-files/${file.id}`),
SENDER,
).expect(200);
// 그 메일에서 첨부처럼 올린 파일이 아니면 404
const other = await client(
request(server).get(
`/api/messages/${sentId}/large-files/${randomUUID()}`,
),
SENDER,
).expect(404);
expect(body(other).error?.code).toBe('RES_001');
});
it('경로는 대소문자를 가린다 — 받기 경로의 대소문자 변형(/api/Messages · LARGE-FILES · 공개 FILES · /API)은 처리기에 닿지 않고 404 RES_001, 받은 수도 그대로 (앞단 nginx 의 받기 제한을 비켜 가지 못하게 — 2026-10-30 인프라 리뷰 High)', async () => {
const file = await uploadReady(SENDER, 'case bytes', '대소문자.pdf');
const { sentId, token, link } = await sendAndShare(SENDER, [file.id]);
const downloadCount = async () =>
(await shareLinksOf(SENDER)).items.find((item) => item.id === link?.id)
?.files[0].downloadCount;
// 소문자 경로는 그대로 — 공개 받기 한 번(센다) · 보낸 사람의 받기 · 내 정보
await publicGet(`/api/public-links/${token}/files/${file.id}`)
.responseType('blob')
.expect(200);
await client(
request(server).get(`/api/messages/${sentId}/large-files/${file.id}`),
SENDER,
)
.responseType('blob')
.expect(200);
await client(request(server).get('/api/auth/me'), SENDER).expect(200);
expect(await downloadCount()).toBe(1);
for (const [label, blocked] of [
[
'포털 · /api/Messages',
() =>
client(
request(server).get(
`/api/Messages/${sentId}/large-files/${file.id}`,
),
SENDER,
),
],
[
'포털 · LARGE-FILES',
() =>
client(
request(server).get(
`/api/messages/${sentId}/LARGE-FILES/${file.id}`,
),
SENDER,
),
],
[
'공개 호스트 · FILES',
() => publicGet(`/api/public-links/${token}/FILES/${file.id}`),
],
[
'공개 호스트 · /API',
() => publicGet(`/API/public-links/${token}/files/${file.id}`),
],
[
'포털 · /API/auth/me',
() => client(request(server).get('/API/auth/me'), SENDER),
],
[
'포털 · /api/Auth/Me',
() => client(request(server).get('/api/Auth/Me'), SENDER),
],
] as const) {
const denied = await blocked();
expect({
label,
status: denied.status,
code: body(denied).error?.code,
}).toEqual({ label, status: 404, code: 'RES_001' });
}
// 변형은 받기 처리기에 닿지 않았다 — 받은 수가 그대로
expect(await downloadCount()).toBe(1);
});
it('링크 끊기 — 그 메일의 파일 모두(되돌릴 수 없다) · 공개 화면 revoked(파일 없음) · 받기 410 · 사용량에서 바로 빠진다, 인용 속 링크의 상태', async () => {
const ME = 'lf.revoke@e2e.arasystem.test';
const first = await uploadReady(ME, 'first file', '하나.zip');
const second = await uploadReady(ME, 'second file!', '둘.zip');
const { token, link } = await sendAndShare(ME, [first.id, second.id]);
const url = `${E2E_SHARE_ORIGIN}/s/${token}`;
const unknownUrl = `${E2E_SHARE_ORIGIN}/s/${randomBytes(32).toString('base64url')}`;
const otherUrl = 'https://other.example/s/abc';
expect(link?.files.map(({ id }) => id)).toEqual([first.id, second.id]);
// 인용 속 링크의 상태 — 누구의 링크든 상태 · 끝나는 날만, 요청 차례 그대로
const lookup = async (urls: string[]) =>
dataOf<{ links: unknown[] }>(
await client(
request(server).post('/api/share-link-lookups'),
STRANGER,
)
.send({ urls })
.expect(200),
).links;
expect(await lookup([url, unknownUrl, otherUrl])).toEqual([
{ url, status: 'active', expiresAt: link?.expiresAt },
{ url: unknownUrl, status: 'unknown', expiresAt: null },
{ url: otherUrl, status: 'unknown', expiresAt: null },
]);
const usedBefore = await quotaOf(ME);
const revoke = (email: string, id: string) =>
client(
request(server).post(`/api/share-links/${id}/revocation`),
email,
);
const revoked = await revoke(ME, link?.id ?? '').expect(200);
expect(dataOf<ShareLinkBody>(revoked)).toMatchObject({
id: link?.id,
subject: SUBJECT,
status: 'revoked',
revokedAt: expect.any(String) as string,
files: [
{ id: first.id, status: 'revoked' },
{ id: second.id, status: 'revoked' },
],
});
// 다시 끊어도 200 (지금 상태)
await revoke(ME, link?.id ?? '').expect(200);
expect(await quotaOf(ME)).toBe(
usedBefore - first.bytes.length - second.bytes.length,
);
expect(
dataOf<PublicLinkBody>(
await publicGet(`/api/public-links/${token}`).expect(200),
),
).toMatchObject({ status: 'revoked', files: [] });
const gone = await publicGet(
`/api/public-links/${token}/files/${first.id}`,
).expect(410);
expect(body(gone).error?.code).toBe('FILE_007');
expect(await lookup([url])).toEqual([
{ url, status: 'revoked', expiresAt: link?.expiresAt },
]);
// 남의 링크 · 모양이 틀린 id 는 404
for (const [email, id] of [
[STRANGER, link?.id ?? ''],
[ME, 'not-a-uuid'],
]) {
const res = await revoke(email, id).expect(404);
expect(body(res).error?.code).toBe('RES_001');
}
// 본문 검사 — 빈 목록 · 겹침 · 21개는 400 VAL_001
for (const urls of [
[],
[url, url],
Array.from({ length: 21 }, (_, index) => `${url}?n=${index}`),
]) {
const res = await client(
request(server).post('/api/share-link-lookups'),
STRANGER,
)
.send({ urls })
.expect(400);
expect(body(res).error?.code).toBe('VAL_001');
}
});
describe('DB 동시성 (실제 PostgreSQL — 대역으로는 볼 수 없다)', () => {
it('받은 수 — 상한 3 에 동시에 10번 더하면 정확히 3번만 된다 (원자적 UPDATE … WHERE download_count < 상한)', async () => {
const ME = 'lf.count@e2e.arasystem.test';
const file = await uploadReady(ME, 'count me', '동시.zip');
const { link } = await sendAndShare(ME, [file.id]);
const repository = app.get(FileSharingRepository, { strict: false });
const results = await Promise.all(
Array.from({ length: 10 }, () =>
repository.countDownload(link?.id ?? '', file.id, 3, new Date()),
),
);
expect(results.filter(Boolean)).toHaveLength(3);
const rows: { download_count: number }[] = await dataSource.query(
'SELECT download_count FROM share_link_files WHERE link_id = $1 AND file_id = $2',
[link?.id, file.id],
);
expect(rows).toEqual([{ download_count: 3 }]);
});
it('만들기 — 같은 직원이 동시에 8개(10MiB씩)를 만들어도 보관량(64MiB)을 넘지 않는다: 정확히 6개 (DB 잠금 안에서 세고 넣는다)', async () => {
const ME = 'lf.burst@e2e.arasystem.test';
const files = app.get(FileStorageService, { strict: false });
const size = 10 * MEGABYTE;
const results = await Promise.allSettled(
Array.from({ length: 8 }, (_, index) =>
files.create(ME, {
name: `동시${index}.zip`,
size,
purpose: 'mail',
lastModified: null,
}),
),
);
const created = results.filter(
(result) => result.status === 'fulfilled',
);
const refused = results.flatMap((result) =>
result.status === 'rejected' ? [result.reason as unknown] : [],
);
expect(created).toHaveLength(6);
expect(
refused.map((reason) =>
reason instanceof BusinessException ? reason.code : 'NOT_BUSINESS',
),
).toEqual(['FILE_005', 'FILE_005']);
expect(await quotaOf(ME)).toBe(6 * size);
});
});
it('예약 발송을 취소하면 그 링크는 없는 링크(404) · 목록에서 빠지고, 되돌린 초안은 덩이를 떼고 파일을 largeFiles 로 준다 — 다시 보내면 덩이는 하나', async () => {
const ME = 'lf.schedule@e2e.arasystem.test';
const file = await uploadReady(ME, 'scheduled bytes', '예약.zip');
const sendAt = new Date(Date.now() + 60 * 60 * 1_000).toISOString();
const { sentId, token, link } = await sendAndShare(ME, [file.id], {
sendAt,
});
// 예약 링크는 보낼 시각 전에도 열린다
await publicGet(`/api/public-links/${token}`).expect(200);
await client(
request(server).delete(`/api/scheduled-messages/${sentId}`),
ME,
).expect(200);
const canceled = await publicGet(`/api/public-links/${token}`).expect(
404,
);
expect(body(canceled).error?.code).toBe('RES_001');
expect((await shareLinksOf(ME)).items.map(({ id }) => id)).not.toContain(
link?.id,
);
// 파일은 남는다 — 보내지 않은 파일로 돌아온다
expect(await unsentIds(ME)).toContain(file.id);
const draft = dataOf<{
body: string;
bodyHtml: string | null;
largeFiles: { id: string; status: string }[];
}>(
await client(request(server).get(`/api/drafts/${sentId}`), ME).expect(
200,
),
);
expect(draft.body).toBe(QUOTE_TEXT);
expect(draft.bodyHtml).toBe(QUOTE_HTML_WITHOUT_MARKER);
expect(draft.largeFiles).toEqual([
expect.objectContaining({ id: file.id, status: 'ready' }) as unknown,
]);
// 되돌린 초안을 다시 보내면 새 링크 — 덩이는 하나 (경계 표시가 없으면 맨 끝)
const again = await sendAndShare(ME, [file.id], {
body: draft.body,
bodyHtml: draft.bodyHtml,
});
expect((again.message.htmlBody ?? '').split(BLOCK_START)).toHaveLength(2);
expect(again.message.body.split(`${E2E_SHARE_ORIGIN}/s/`)).toHaveLength(
2,
);
expect(again.token).not.toBe(token);
expect(again.link?.status).toBe('active');
});
});
/**
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
*/
/**
* 12단계 — 계정 보안 (계약 5절). 엔진의 2단계 인증 · 앱 비밀번호는 대역(fakeAccountSecurityProvider)이고, 세션 · 로그인 기록은 실제 DB 다.
* 클라이언트 IP 는 X-Forwarded-For 로 시나리오마다 나눈다(198.18.x.x — 다른 시나리오와 겹치지 않게)
*/
describe('12단계 — 계정 보안 (2단계 인증 · 앱 비밀번호 · 로그인된 브라우저 · 로그인 기록 · 엔진 웹훅)', () => {
const CHROME =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0';
const SAFARI =
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.6 Mobile/15E148 Safari/604.1';
const loginAs = (
email: string,
from: string,
extra: object = {},
userAgent = CHROME,
) =>
request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', from)
.set('User-Agent', userAgent)
.send({ email, password: CORRECT_PASSWORD, ...extra });
/** 로그인해 두 쿠키를 받는다 — 세션 id(sid)는 리프레시 토큰에서 읽는다 */
const signIn = async (email: string, from: string, userAgent = CHROME) => {
const res = await loginAs(email, from, {}, userAgent).expect(200);
const access = cookieValue(setCookie(res, 'access_token'));
const refresh = cookieValue(setCookie(res, 'refresh_token'));
const { sid } = jwt.decode<{ sid: string }>(refresh);
return {
sid,
cookies: [`access_token=${access}`, `refresh_token=${refresh}`],
access: `access_token=${access}`,
};
};
afterEach(() => {
e2eAppNetworkOpen = true;
});
describe('2단계 인증', () => {
const ME = 'otp.flow@e2e.arasystem.test';
it('등록 → 켜기 → 로그인 두 단계(쿠키는 코드 뒤에만) → 2분 안에 다시 쓴 코드는 AUTH_006 → 끄기', async () => {
const session = await signIn(ME, '198.18.1.1');
const asMe = (req: HttpTest) => req.set('Cookie', session.cookies);
const before = await asMe(
request(server).get('/api/two-factor'),
).expect(200);
expect(body(before).data).toEqual({
isEnabled: false,
enabledAt: null,
isRequired: false,
deadline: null,
});
const enrolled = await asMe(
request(server).post('/api/two-factor/enrollments'),
).expect(201);
expect(enrolled.headers['cache-control']).toBe('private, no-store');
const enrollment = dataOf<{
enrollmentId: string;
secret: string;
otpauthUrl: string;
expiresAt: string;
}>(enrolled);
expect(enrollment.secret).toMatch(/^[A-Z2-7]{32}$/);
expect(enrollment.otpauthUrl).toBe(
`otpauth://totp/ARA%20SYSTEM:otp.flow%40e2e.arasystem.test?secret=${enrollment.secret}&issuer=ARA%20SYSTEM&algorithm=SHA1&digits=6&period=30`,
);
const secret = decodeBase32(enrollment.secret);
// 틀린 코드 — 엔진에 넣지 않는다
const wrongCode = otpCodeOf(secret) === '000000' ? '111111' : '000000';
const wrong = await asMe(
request(server).post('/api/two-factor/activation'),
)
.send({
enrollmentId: enrollment.enrollmentId,
otpCode: wrongCode,
currentPassword: CORRECT_PASSWORD,
})
.expect(400);
expect(body(wrong).error?.code).toBe('AUTH_006');
expect(twoFactorUrls.has(ME)).toBe(false);
const activationCode = otpCodeOf(secret);
const activated = await asMe(
request(server).post('/api/two-factor/activation'),
)
.send({
enrollmentId: enrollment.enrollmentId,
otpCode: activationCode,
currentPassword: CORRECT_PASSWORD,
})
.expect(200);
expect(body(activated).data).toEqual({
isEnabled: true,
enabledAt: expect.any(String) as unknown,
isRequired: false,
deadline: null,
});
expect(twoFactorUrls.get(ME)).toBe(enrollment.otpauthUrl);
// 1) 비밀번호만 — twoFactorRequired, 쿠키 없음
const first = await loginAs(ME, '198.18.1.2').expect(200);
expect(body(first).data).toEqual({
status: 'twoFactorRequired',
email: null,
name: null,
...UNRESTRICTED,
});
expect(first.headers['set-cookie']).toBeUndefined();
// 켤 때 쓴 코드는 2분 동안 다시 받지 않는다 (엔진은 받지만 서버가 막는다)
const reused = await loginAs(ME, '198.18.1.3', {
otpCode: activationCode,
}).expect(400);
expect(body(reused).error?.code).toBe('AUTH_006');
// 2) 비밀번호 + 새 코드 — signedIn + 쿠키
const loginCode = otpCodeOf(secret, 1);
const second = await loginAs(ME, '198.18.1.4', {
otpCode: loginCode,
}).expect(200);
expect(body(second).data).toEqual({
status: 'signedIn',
email: ME,
name: EMPLOYEE.name,
...UNRESTRICTED,
});
expect(cookieValue(setCookie(second, 'access_token'))).not.toBe('');
const again = await loginAs(ME, '198.18.1.5', {
otpCode: loginCode,
}).expect(400);
expect(body(again).error?.code).toBe('AUTH_006');
const malformed = await loginAs(ME, '198.18.1.6', {
otpCode: '12-34',
}).expect(400);
expect(body(malformed).error?.code).toBe('VAL_001');
const status = await asMe(
request(server).get('/api/two-factor'),
).expect(200);
expect(body(status).data).toMatchObject({ isEnabled: true });
const off = await asMe(
request(server).post('/api/two-factor/deactivation'),
)
.send({
currentPassword: CORRECT_PASSWORD,
otpCode: otpCodeOf(secret, -1),
})
.expect(200);
expect(body(off).data).toEqual({
isEnabled: false,
enabledAt: null,
isRequired: false,
deadline: null,
});
expect(twoFactorUrls.has(ME)).toBe(false);
// 다시 비밀번호만으로 로그인한다
const plain = await loginAs(ME, '198.18.1.7').expect(200);
expect(body(plain).data).toMatchObject({ status: 'signedIn' });
});
it('otpCode 가 null 이면 400 VAL_001 — 칸을 빼고 보낸다(검사를 비켜 서비스에서 500 이 나던 길)', async () => {
const res = await loginAs('otp.null@e2e.arasystem.test', '198.18.1.9', {
otpCode: null,
}).expect(400);
expect(body(res).error?.code).toBe('VAL_001');
const changed = await request(server)
.put('/api/auth/password')
.set('Cookie', cookieOf('otp.null@e2e.arasystem.test'))
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
otpCode: null,
})
.expect(400);
expect(body(changed).error?.code).toBe('VAL_001');
});
it('등록이 없거나 다른 등록 id 면 409 AUTH_008 · 지금 비밀번호가 틀리면 400 AUTH_005 · 앱 비밀번호도 AUTH_005', async () => {
const OTHER = 'otp.errors@e2e.arasystem.test';
const session = await signIn(OTHER, '198.18.2.1');
const enrolled = await request(server)
.post('/api/two-factor/enrollments')
.set('Cookie', session.cookies)
.expect(201);
const { enrollmentId, secret } = dataOf<{
enrollmentId: string;
secret: string;
}>(enrolled);
const code = otpCodeOf(decodeBase32(secret));
const missing = await request(server)
.post('/api/two-factor/activation')
.set('Cookie', session.cookies)
.send({
enrollmentId: '00000000-0000-4000-8000-000000000000',
otpCode: code,
currentPassword: CORRECT_PASSWORD,
})
.expect(409);
expect(body(missing).error?.code).toBe('AUTH_008');
for (const currentPassword of [
'wrong-password',
`app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
]) {
const rejected = await request(server)
.post('/api/two-factor/activation')
.set('Cookie', session.cookies)
.send({ enrollmentId, otpCode: code, currentPassword })
.expect(400);
expect(body(rejected).error?.code).toBe('AUTH_005');
}
expect(twoFactorUrls.has(OTHER)).toBe(false);
});
it('2단계 인증을 켠 직원의 비밀번호 바꾸기 — 코드가 없으면 AUTH_007, 코드와 함께 바꾸면 앱 비밀번호가 모두 지워진다', async () => {
const OTP_USER = 'otp.password@e2e.arasystem.test';
const session = await signIn(OTP_USER, '198.18.3.1');
// 엔진 관리 화면에서 켠 것처럼 대역에 바로 둔다
const secret = Buffer.alloc(20, 3);
twoFactorUrls.set(
OTP_USER,
`otpauth://totp/ARA%20SYSTEM:x?secret=${'AMBQGAYDAMBQGAYDAMBQGAYDAMBQGAYD'}&issuer=ARA%20SYSTEM`,
);
appPasswordsByUser.set(OTP_USER, [
{ id: 'b', name: '회사 PC', createdAt: '2026-10-30T00:00:00.000Z' },
]);
const noCode = await request(server)
.put('/api/auth/password')
.set('Cookie', session.cookies)
.set('X-Forwarded-For', '198.18.3.2')
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
})
.expect(400);
expect(body(noCode).error?.code).toBe('AUTH_007');
await request(server)
.put('/api/auth/password')
.set('Cookie', session.cookies)
.set('X-Forwarded-For', '198.18.3.3')
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-1234',
otpCode: otpCodeOf(secret),
})
.expect(200);
expect(appPasswordsByUser.get(OTP_USER)).toEqual([]);
// 2단계 인증은 그대로 남는다
expect(twoFactorUrls.has(OTP_USER)).toBe(true);
twoFactorUrls.delete(OTP_USER);
});
});
describe('앱 비밀번호 (/api/app-passwords)', () => {
const ME = 'apppw.me@e2e.arasystem.test';
it('만들기(값은 이 응답에만 · no-store) → 목록(최근 것이 위) → 같은 이름 VAL_001 → 상한 AUTH_009 → 지우기(이미 없어도 200)', async () => {
const session = await signIn(ME, '198.18.4.1');
const create = (name: string) =>
request(server)
.post('/api/app-passwords')
.set('Cookie', session.cookies)
.send({ name });
const created = await create(' 회사 PC 아웃룩 ').expect(201);
expect(created.headers['cache-control']).toBe('private, no-store');
expect(dataOf(created)).toEqual({
id: expect.any(String) as unknown,
name: '회사 PC 아웃룩',
createdAt: expect.any(String) as unknown,
lastUsed: null,
password: expect.stringMatching(/^app_.{36}$/) as unknown,
});
const firstId = dataOf<{ id: string }>(created).id;
const taken = await create('회사 PC 아웃룩').expect(400);
expect(body(taken).error?.code).toBe('VAL_001');
const empty = await create(' ').expect(400);
expect(body(empty).error?.code).toBe('VAL_001');
const long = await create('가'.repeat(41)).expect(400);
expect(body(long).error?.code).toBe('VAL_001');
for (const name of ['휴대폰', '태블릿', '집 PC', '노트북']) {
await create(name).expect(201);
}
const full = await create('여섯째').expect(409);
expect(body(full).error?.code).toBe('AUTH_009');
const listed = await request(server)
.get('/api/app-passwords')
.set('Cookie', session.cookies)
.expect(200);
const page = dataOf<{
items: { id: string; name: string; password?: string }[];
pageInfo: unknown;
}>(listed);
expect(page.items.map(({ name }) => name)).toEqual([
'노트북',
'집 PC',
'태블릿',
'휴대폰',
'회사 PC 아웃룩',
]);
expect(page.items.every((item) => item.password === undefined)).toBe(
true,
);
expect(page.pageInfo).toEqual({
hasNext: false,
nextCursor: null,
total: 5,
});
await request(server)
.delete(`/api/app-passwords/${firstId}`)
.set('Cookie', session.cookies)
.expect(200);
await request(server)
.delete(`/api/app-passwords/${firstId}`)
.set('Cookie', session.cookies)
.expect(200);
expect(appPasswordsByUser.get(ME)).toHaveLength(4);
});
it('앱 비밀번호로는 웹메일에 로그인할 수 없다 — 엔진에 묻지 않고 401 AUTH_004', async () => {
const checksBefore = engineChecks.get(ME) ?? 0;
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.4.9')
.send({
email: ME,
password: `app_${'abcdefghijklmnopqrstuvwxyz0123791203'}`,
})
.expect(401);
expect(body(res).error?.code).toBe('AUTH_004');
expect(engineChecks.get(ME) ?? 0).toBe(checksBefore);
});
});
describe('로그인된 브라우저 (/api/auth/sessions)', () => {
const ME = 'sessions.me@e2e.arasystem.test';
it('지금 세션이 맨 앞 · 하나 끊으면 그 액세스 토큰이 바로 401 · 지금 세션은 VAL_001 · 없는 것은 RES_001 · 다른 곳 모두 끊기', async () => {
const current = await signIn(ME, '198.18.5.1', CHROME);
const phone = await signIn(ME, '198.18.5.2', SAFARI);
const third = await signIn(ME, '198.18.5.3', CHROME);
const listed = await request(server)
.get('/api/auth/sessions')
.set('Cookie', current.cookies)
.expect(200);
const items = dataOf<{
items: {
id: string;
isCurrent: boolean;
ip: string | null;
device: { browser: string | null; os: string | null } | null;
}[];
pageInfo: { total: number };
}>(listed);
expect(items.items[0]).toMatchObject({
id: current.sid,
isCurrent: true,
ip: '198.18.5.1',
device: { browser: 'Edge', os: 'Windows' },
});
expect(items.items.find(({ id }) => id === phone.sid)).toMatchObject({
isCurrent: false,
device: { browser: 'Safari', os: 'iOS' },
});
expect(items.pageInfo.total).toBe(3);
// 끊기 전에는 그 브라우저의 액세스 토큰이 된다
await request(server)
.get('/api/auth/me')
.set('Cookie', phone.access)
.expect(200);
await request(server)
.delete(`/api/auth/sessions/${phone.sid}`)
.set('Cookie', current.cookies)
.expect(200);
// 끊은 세션의 액세스 토큰은 만료 전이어도 바로 401
const revoked = await request(server)
.get('/api/auth/me')
.set('Cookie', phone.access)
.expect(401);
expect(body(revoked).error?.code).toBe('AUTH_001');
const self = await request(server)
.delete(`/api/auth/sessions/${current.sid}`)
.set('Cookie', current.cookies)
.expect(400);
expect(body(self).error?.code).toBe('VAL_001');
const unknown = await request(server)
.delete(`/api/auth/sessions/${randomUUID()}`)
.set('Cookie', current.cookies)
.expect(404);
expect(body(unknown).error?.code).toBe('RES_001');
await request(server)
.delete(`/api/auth/sessions/${phone.sid}`)
.set('Cookie', current.cookies)
.expect(404);
const ended = await request(server)
.delete('/api/auth/sessions')
.set('Cookie', current.cookies)
.expect(200);
expect(body(ended).data).toEqual({ ended: 1 });
await request(server)
.get('/api/auth/me')
.set('Cookie', third.access)
.expect(401);
// 이 세션은 그대로 쓴다
await request(server)
.get('/api/auth/me')
.set('Cookie', current.access)
.expect(200);
});
it('로그아웃한 세션의 액세스 토큰도 바로 401 (12단계 — 만료까지 기다리지 않는다)', async () => {
const session = await signIn(ME, '198.18.5.9');
await request(server)
.post('/api/auth/logout')
.set('Cookie', session.cookies)
.expect(200);
await request(server)
.get('/api/auth/me')
.set('Cookie', session.access)
.expect(401);
});
});
describe('로그인 기록 (/api/login-events)', () => {
const ME = 'history.me@e2e.arasystem.test';
/**
* 비밀번호 틀림은 사내 목록에 있는 주소만 남긴다 — 사내 목록은 10분 기억하고 다른 시나리오(8단계)가 채우므로,
* 이 시나리오는 사내 목록의 답을 직접 정한다(테스트 차례와 상관없게)
*/
let directorySpy: jest.SpyInstance | null = null;
const directoryHas = (known: boolean) => {
directorySpy = jest
.spyOn(app.get(DirectoryService, { strict: false }), 'hasEmail')
.mockResolvedValue(known);
};
afterEach(() => {
directorySpy?.mockRestore();
directorySpy = null;
});
it('사내 목록에 없는 주소의 비밀번호 틀림은 남기지 않는다 (아무 주소나 뿌려 표를 채우지 못하게)', async () => {
const STRANGER = 'history.stranger@e2e.arasystem.test';
directoryHas(false);
await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.6.9')
.send({ email: STRANGER, password: 'wrong-password' })
.expect(401);
const rows: unknown[] = await dataSource.query(
'SELECT id FROM login_events WHERE user_email = $1',
[STRANGER],
);
expect(rows).toEqual([]);
});
it('웹메일 성공 · 실패가 최근 차례로 · isSuccess=false 는 실패만 · 커서로 이어 읽는다 · 다른 거르기의 커서는 VAL_001', async () => {
directoryHas(true);
await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.6.1')
.send({ email: ME, password: 'wrong-password' })
.expect(401);
const session = await signIn(ME, '198.18.6.2');
const all = await request(server)
.get('/api/login-events?limit=1')
.set('Cookie', session.cookies)
.expect(200);
const firstPage = dataOf<{
items: Record<string, unknown>[];
pageInfo: {
hasNext: boolean;
nextCursor: string | null;
total: number;
};
}>(all);
expect(firstPage.pageInfo).toMatchObject({ hasNext: true, total: 2 });
expect(firstPage.items[0]).toMatchObject({
method: 'webmail',
isSuccess: true,
isTwoFactor: false,
failureReason: null,
ip: '198.18.6.2',
device: { browser: 'Edge', os: 'Windows' },
appPassword: null,
});
const next = await request(server)
.get(
`/api/login-events?limit=1&cursor=${firstPage.pageInfo.nextCursor ?? ''}`,
)
.set('Cookie', session.cookies)
.expect(200);
expect(dataOf<{ items: unknown[] }>(next).items).toEqual([
expect.objectContaining({
isSuccess: false,
failureReason: 'wrongPassword',
ip: '198.18.6.1',
}),
]);
const failures = await request(server)
.get('/api/login-events?isSuccess=false')
.set('Cookie', session.cookies)
.expect(200);
expect(
dataOf<{ items: { isSuccess: boolean }[] }>(failures).items.every(
({ isSuccess }) => !isSuccess,
),
).toBe(true);
const otherScope = await request(server)
.get(
`/api/login-events?isSuccess=false&cursor=${firstPage.pageInfo.nextCursor ?? ''}`,
)
.set('Cookie', session.cookies)
.expect(400);
expect(body(otherScope).error?.code).toBe('VAL_001');
const badFilter = await request(server)
.get('/api/login-events?isSuccess=maybe')
.set('Cookie', session.cookies)
.expect(400);
expect(body(badFilter).error?.code).toBe('VAL_001');
});
});
describe('요청 대상 원문 검사 (인프라 리뷰 — nginx 가 닫은 경로가 parseurl 의 옛 url.parse 로 열리던 길)', () => {
/** 원문 바이트 그대로 보낸다 — supertest 는 백슬래시를 `/` 로 고쳐 보낸다 */
const sendRaw = (
httpServer: Server,
method: string,
target: string,
): Promise<{ status: number; text: string }> => {
const address = httpServer.address();
const port = typeof address === 'object' && address ? address.port : 0;
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1');
const chunks: Buffer[] = [];
socket.on('data', (chunk: Buffer) => chunks.push(chunk));
socket.on('error', reject);
socket.on('end', () => {
const text = Buffer.concat(chunks).toString('utf8');
resolve({ status: Number(text.split(' ')[1]), text });
});
socket.write(
`${method} ${target} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}`,
'latin1',
);
});
};
it('날 `\\` · `#` 이 든 대상은 라우팅 전에 표준 실패 형식의 400 VAL_001 — 웹훅 가드 · 헬스체크에 닿지 않는다, 정상 경로는 그대로', async () => {
const httpServer = app.getHttpServer();
const startedServer = !httpServer.listening;
if (startedServer) await listenOnAnyPort(httpServer);
try {
for (const [method, target] of [
['POST', '/api/internal\\mail-engine-events#x'],
['GET', '/api/health\\#a'],
['GET', '/api/auth/me#x'],
] as const) {
const res = await sendRaw(httpServer, method, target);
expect(res.status).toBe(400);
expect(res.text).toContain('"code":"VAL_001"');
expect(res.text).toMatch(/"traceId":"[^"]+"/);
}
// 정상 경로 · 퍼센트 인코딩한 한글은 라우터까지 간다 (로그인하지 않았으니 401)
const normal = await sendRaw(
httpServer,
'GET',
'/api/messages?q=%ED%95%9C%EA%B8%80',
);
expect(normal.status).toBe(401);
expect(normal.text).toContain('"code":"AUTH_001"');
} finally {
if (startedServer) await closeServer(httpServer);
}
});
});
describe('엔진 웹훅 (POST /api/internal/mail-engine-events)', () => {
const ME = 'webhook.me@e2e.arasystem.test';
const signature = (raw: string, secret = E2E_WEBHOOK_SECRET) =>
createHmac('sha256', Buffer.from(secret, 'utf8'))
.update(raw)
.digest('base64');
const deliver = (raw: string, signed = signature(raw)) =>
request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'application/json')
.set('X-Signature', signed)
// setup 이 붙이는 표시 머리글 — 서버는 쓰지 않는다
.set('X-Arasystem-Setup', '[setup.mjs] key=e2e')
.send(raw);
it('서명한 빈 묶음은 200 (setup 이 받는 쪽을 확인하는 방법)', async () => {
const res = await deliver('{"events":[]}').expect(200);
expect(body(res)).toEqual({ success: true, data: null });
});
it('서명한 IMAP 로그인 이벤트는 로그인 기록에 남는다 — 엔진이 다시 보낸 묶음(이벤트 id 만 다르다)은 겹치지 않는다', async () => {
const event = (id: string) => ({
id,
createdAt: '2026-10-31T00:00:05Z',
type: 'auth.success',
data: {
listenerId: 'imaps',
localPort: 993,
remoteIp: '211.186.95.252',
remotePort: 51234,
accountName: ME,
accountId: 9,
spanId: 777,
},
});
await deliver(JSON.stringify({ events: [event('first')] })).expect(200);
await deliver(JSON.stringify({ events: [event('retried')] })).expect(
200,
);
const rows: { method: string; ip: string; is_success: boolean }[] =
await dataSource.query(
'SELECT method, ip, is_success FROM login_events WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([
{ method: 'imap', ip: '211.186.95.252', is_success: true },
]);
});
// 6,000행을 넣는다 — 개발 DB 의 쿼리 로그가 켜져 있으면 혼자 3초 남짓이라 기본 한도(5초)보다 넉넉히 둔다
it('큰 묶음(6,000건 — 한 문장에 넣으면 매개변수 상한 65,535 를 넘는 크기)도 200 — 나눠 넣어 모두 남는다', async () => {
const BIG = 'webhook.big@e2e.arasystem.test';
const events = Array.from({ length: 6_000 }, (_, index) => ({
id: `big-${index}`,
createdAt: '2026-10-31T00:10:00Z',
type: 'auth.success',
data: {
listenerId: 'imaps',
localPort: 993,
remoteIp: '211.186.95.252',
remotePort: 10_000 + index,
accountName: BIG,
accountId: 9,
spanId: 50_000 + index,
},
}));
await deliver(JSON.stringify({ events })).expect(200);
const [{ count }]: { count: number }[] = await dataSource.query(
'SELECT count(*)::int AS count FROM login_events WHERE user_email = $1',
[BIG],
);
expect(count).toBe(6_000);
}, 60_000);
it('서명이 없거나 틀리면 · 앱 네트워크 밖의 접속이면 · JSON 이 아니면 401 AUTH_001 — 아무것도 남기지 않는다', async () => {
const raw = JSON.stringify({ events: [] });
const unsigned = await request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'application/json')
.send(raw)
.expect(401);
expect(body(unsigned).error?.code).toBe('AUTH_001');
await deliver(raw, signature(raw, `${E2E_WEBHOOK_SECRET}x`)).expect(
401,
);
await deliver(raw, signature('{"events":[1]}')).expect(401);
const plain = await request(server)
.post('/api/internal/mail-engine-events')
.set('Content-Type', 'text/plain')
.set('X-Signature', signature(raw))
.send(raw)
.expect(401);
expect(body(plain).error?.code).toBe('AUTH_001');
e2eAppNetworkOpen = false;
await deliver(raw).expect(401);
});
});
});
describe('14단계 1차 — 검색 색인 바탕 (GET /api/search-index · 색인 표 · 채우기)', () => {
const ME = 'search.index@e2e.arasystem.test';
const OTHER = 'search.other@e2e.arasystem.test';
const FROM_IP = '198.51.100.140';
const signIn = async (email: string) => {
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', FROM_IP)
.send({ email, password: CORRECT_PASSWORD })
.expect(200);
return `access_token=${cookieValue(setCookie(res, 'access_token'))}`;
};
const statusOf = (cookie: string) =>
request(server).get('/api/search-index').set('Cookie', cookie);
afterEach(async () => {
fakeIndexSource.mailboxes.clear();
for (const table of ['mail_search_messages', 'mail_search_states']) {
await dataSource.query(
`DELETE FROM "${table}" WHERE user_email = ANY($1::text[])`,
[[ME, OTHER]],
);
}
});
it('로그인하면 상태 줄이 building 으로 생긴다 — 준비 상태는 { building, 0, null } · 로그인 없이는 401 AUTH_001', async () => {
const cookie = await signIn(ME);
const rows: { status: string; email_state: string | null }[] =
await dataSource.query(
'SELECT status, email_state FROM mail_search_states WHERE user_email = $1',
[ME],
);
expect(rows).toEqual([{ status: 'building', email_state: null }]);
const res = await statusOf(cookie).expect(200);
expect(dataOf(res)).toEqual({ status: 'building', done: 0, total: null });
const anonymous = await request(server)
.get('/api/search-index')
.expect(401);
expect(body(anonymous).error?.code).toBe('AUTH_001');
});
it('상태 줄이 없던 직원은 준비 상태를 물을 때 만든다 · 멈춘 줄은 로그인하면 푼다', async () => {
// 세션 없이 서명만 맞춘 쿠키 — 로그인을 거치지 않았다
await statusOf(cookieOf(OTHER)).expect(200);
await dataSource.query(
`UPDATE mail_search_states SET paused_reason = 'locked', failure_count = 3 WHERE user_email = $1`,
[OTHER],
);
await signIn(OTHER);
const rows: { paused_reason: string | null; failure_count: number }[] =
await dataSource.query(
'SELECT paused_reason, failure_count FROM mail_search_states WHERE user_email = $1',
[OTHER],
);
expect(rows).toEqual([{ paused_reason: null, failure_count: 0 }]);
});
it('채우기 → ready — 색인 줄의 글자 두 개 묶음(GIN)과 접기 글(strpos)로 부분 일치를 찾는다 (실제 PostgreSQL)', async () => {
const cookie = await signIn(ME);
fakeIndexSource.mailboxes.set(ME, {
state: 's1',
emails: [
indexDocument({
id: 'm-contract',
subject: 'Re: [공지] 11월 계약서',
bodyText: '계약서를 보내 드립니다. Project PLAN 첨부',
attachmentNames: ['계약서.PDF'],
hasAttachment: true,
mailboxIds: ['inbox', 'archive'],
keywords: ['$seen'],
receivedAt: '2026-11-04T01:00:00Z',
}),
indexDocument({
id: 'm-meeting',
subject: '회의록',
bodyText: '내일 회의는 3층 회의실',
receivedAt: '2026-11-05T01:00:00Z',
}),
],
});
const build = app.get(MailSearchBuildService);
// 마무리(따라잡기 · 대조)는 남은 시간이 FINISH_MIN_REMAINING_MS 이상일 때만 시작한다 — 넉넉히
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
expect(dataOf(await statusOf(cookie).expect(200))).toEqual({
status: 'ready',
done: 2,
total: 2,
});
const find = async (column: string, word: string, grams: string[]) =>
(
await dataSource.query<{ email_id: string }[]>(
`SELECT email_id FROM mail_search_messages WHERE user_email = $1 AND grams @> $2::text[] AND strpos(${column}, $3) > 0 ORDER BY received_at DESC`,
[ME, grams, word],
)
).map((row) => row.email_id);
// 붙여 쓴 말 · 앞부분 · 말머리 · 대소문자
expect(await find('fold_body', '계약', ['계약'])).toEqual(['m-contract']);
expect(await find('fold_body', 'proj', ['pr', 'ro', 'oj'])).toEqual([
'm-contract',
]);
expect(await find('fold_subject', '공지', ['공지'])).toEqual([
'm-contract',
]);
expect(await find('fold_attachment', '.pdf', ['.p', 'pd', 'df'])).toEqual(
['m-contract'],
);
expect(await find('fold_body', '회의', ['회의'])).toEqual(['m-meeting']);
// 메일함 범위(GIN) · 배열 칸이 그대로 들어갔다
const inArchive: { email_id: string; keywords: string[] }[] =
await dataSource.query(
`SELECT email_id, keywords FROM mail_search_messages WHERE user_email = $1 AND mailbox_ids && $2::text[]`,
[ME, ['archive']],
);
expect(inArchive).toEqual([
{ email_id: 'm-contract', keywords: ['$seen'] },
]);
});
it('표 쓰기 — 넣기 · 메일함 · 키워드만 바꾸기(한 문장) · 지우기, 상태 줄이 없으면 아무것도 쓰지 않는다', async () => {
await statusOf(cookieOf(OTHER)).expect(200);
const repository = app.get(MailSearchIndexRepository);
const now = new Date();
const row = toIndexRow(indexDocument({ id: 'w1', subject: '견적' }));
expect(
(
await repository.write(
OTHER,
{ upserts: [row, { ...row, emailId: 'w2' }] },
now,
)
).written,
).toBe(true);
const updated = await repository.write(
OTHER,
{
updates: [
{
id: 'w1',
mailboxIds: ['trash'],
keywords: ['$seen', '$flagged'],
},
{ id: 'not-indexed', mailboxIds: ['inbox'], keywords: [] },
],
deletes: ['w2'],
state: { emailState: 's9', lastSyncedAt: now },
},
now,
);
expect([...updated.updatedIds]).toEqual(['w1']);
expect(await repository.indexedIds(OTHER)).toEqual(['w1']);
expect([...(await repository.existingIds(OTHER, ['w1', 'w2']))]).toEqual([
'w1',
]);
const [stored]: { mailbox_ids: string[]; keywords: string[] }[] =
await dataSource.query(
'SELECT mailbox_ids, keywords FROM mail_search_messages WHERE user_email = $1',
[OTHER],
);
expect(stored).toEqual({
mailbox_ids: ['trash'],
keywords: ['$seen', '$flagged'],
});
expect((await repository.findState(OTHER))?.emailState).toBe('s9');
expect(await repository.recordFailure(OTHER, now)).toBe(1);
// 퇴사 마무리처럼 상태 줄 → 색인 줄을 지우면, 뒤늦은 쓰기는 아무것도 하지 않는다
await dataSource.query(
'DELETE FROM mail_search_states WHERE user_email = $1',
[OTHER],
);
await dataSource.query(
'DELETE FROM mail_search_messages WHERE user_email = $1',
[OTHER],
);
expect(
(await repository.write(OTHER, { upserts: [row] }, now)).written,
).toBe(false);
expect(await repository.countMessages(OTHER)).toBe(0);
});
});
describe('13단계 — 제한된 세션 · 2단계 인증 필수 · 문의 연락처', () => {
let systemSettings: SystemSettingsService;
/** 시작 전 설정 — 끝나면 되돌린다(컨테이너 안에서 돌리면 개발 DB 다). 준비가 실패했으면 없다 */
let original: SystemSettingsPatch | undefined;
/** 처음 값 — 시험마다 이것에서 시작한다 */
const DEFAULTS: SystemSettingsPatch = {
twoFactor: { required: false, startDate: null, exempt: [] },
supportContact: null,
};
const loginAs = (email: string, from: string) =>
request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', from)
.send({ email, password: CORRECT_PASSWORD });
const cookiesOf = (res: Response) => [
`access_token=${cookieValue(setCookie(res, 'access_token'))}`,
`refresh_token=${cookieValue(setCookie(res, 'refresh_token'))}`,
];
const settle = (patch: SystemSettingsPatch) =>
systemSettings.update(patch, new Date());
beforeAll(async () => {
systemSettings = app.get(SystemSettingsService, { strict: false });
const current = await systemSettings.reload();
original = {
twoFactor: { ...current.twoFactor },
supportContact: current.supportContact,
};
});
beforeEach(() => settle(DEFAULTS));
afterAll(async () => {
if (original) await settle(original);
});
it('비밀번호가 만료된 직원 — 쿠키는 받되 passwordChange, 허용한 경로만 부르고 그 밖은 403 AUTH_010 → 바꾸면 풀린다', async () => {
const ME = 'expired.me@e2e.arasystem.test';
expiredPasswords.add(ME);
try {
const res = await loginAs(ME, '198.18.13.1').expect(200);
expect(body(res).data).toEqual({
status: 'signedIn',
email: ME,
name: EMPLOYEE.name,
restriction: 'passwordChange',
twoFactorDeadline: null,
});
const cookies = cookiesOf(res);
const as = (test: HttpTest) => test.set('Cookie', cookies);
const me = await as(request(server).get('/api/auth/me')).expect(200);
expect(body(me).data).toEqual({
...EMPLOYEE,
email: ME,
restriction: 'passwordChange',
twoFactorDeadline: null,
});
await as(request(server).get('/api/auth/sessions')).expect(200);
await as(request(server).get('/api/two-factor')).expect(200);
await as(request(server).get('/api/support-contact')).expect(200);
for (const path of [
'/api/mailboxes',
'/api/mail-state',
'/api/mail-events',
'/api/contacts',
'/api/app-passwords',
]) {
const blocked = await as(request(server).get(path)).expect(403);
expect(body(blocked).error?.code).toBe('AUTH_010');
}
// 갱신도 엔진에 다시 묻는다 — 아직 만료면 그대로
const still = await as(
request(server).post('/api/auth/refresh'),
).expect(200);
expect(body(still).data).toMatchObject({
restriction: 'passwordChange',
});
// 새 비밀번호 — 엔진이 만료를 풀고 서버가 세션의 제한을 지운다
const fresh = cookiesOf(still);
await request(server)
.put('/api/auth/password')
.set('X-Forwarded-For', '198.18.13.1')
.set('Cookie', fresh)
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-for-e2e-13',
})
.expect(200);
// 제한을 푼 뒤에는 옛 액세스 토큰(rst 클레임)으로도 세션 표의 지금 값 — 마친 단계가 다시 보이지 않는다
const after = await request(server)
.get('/api/auth/me')
.set('Cookie', fresh)
.expect(200);
expect(body(after).data).toMatchObject(UNRESTRICTED);
await request(server)
.get('/api/mailboxes')
.set('Cookie', fresh)
.expect(200);
const renewed = await request(server)
.post('/api/auth/refresh')
.set('Cookie', fresh)
.expect(200);
expect(body(renewed).data).toEqual({
...EMPLOYEE,
email: ME,
...UNRESTRICTED,
});
} finally {
expiredPasswords.delete(ME);
}
});
it('2단계 인증 필수 — 시작일부터 켜지 않은 직원은 twoFactorSetup · 끄기는 400 VAL_001, 유예 중에는 시작 시각만, 예외 주소는 그대로', async () => {
const ME = 'policy.me@e2e.arasystem.test';
const today = kstDateString(new Date());
await settle({ twoFactor: { required: true, startDate: today } });
const res = await loginAs(ME, '198.18.13.2').expect(200);
expect(body(res).data).toMatchObject({
restriction: 'twoFactorSetup',
twoFactorDeadline: null,
});
const cookies = cookiesOf(res);
const status = await request(server)
.get('/api/two-factor')
.set('Cookie', cookies)
.expect(200);
expect(body(status).data).toEqual({
isEnabled: false,
enabledAt: null,
isRequired: true,
deadline: null,
});
const blocked = await request(server)
.get('/api/mailboxes')
.set('Cookie', cookies)
.expect(403);
expect(body(blocked).error?.code).toBe('AUTH_010');
// 유예 — 시작일이 내일이면 제한 없이 시작 시각(내일 0시 KST)만
const tomorrow = kstDateString(new Date(Date.now() + 24 * 3_600_000));
await settle({ twoFactor: { startDate: tomorrow } });
const grace = await loginAs(ME, '198.18.13.3').expect(200);
expect(body(grace).data).toMatchObject({
restriction: null,
twoFactorDeadline: kstStartOfDate(tomorrow)?.toISOString(),
});
const graceStatus = await request(server)
.get('/api/two-factor')
.set('Cookie', cookiesOf(grace))
.expect(200);
expect(body(graceStatus).data).toMatchObject({
isRequired: true,
deadline: kstStartOfDate(tomorrow)?.toISOString(),
});
// 예외 주소는 정책을 따르지 않는다
await settle({ twoFactor: { startDate: today, exempt: [ME] } });
const exempt = await loginAs(ME, '198.18.13.4').expect(200);
expect(body(exempt).data).toMatchObject(UNRESTRICTED);
});
it('만료 계정 + 2단계 필수 시작 — 비밀번호를 바꾸면 그 세션은 twoFactorSetup 으로 바뀌어 옛 토큰으로도 다음 단계 밖은 막힌다 (리뷰 M2)', async () => {
const ME = 'expired.policy@e2e.arasystem.test';
expiredPasswords.add(ME);
try {
await settle({
twoFactor: { required: true, startDate: kstDateString(new Date()) },
});
const res = await loginAs(ME, '198.18.13.6').expect(200);
// 비밀번호 만료가 먼저다
expect(body(res).data).toMatchObject({ restriction: 'passwordChange' });
const cookies = cookiesOf(res);
await request(server)
.put('/api/auth/password')
.set('X-Forwarded-For', '198.18.13.6')
.set('Cookie', cookies)
.send({
currentPassword: CORRECT_PASSWORD,
newPassword: 'new-password-for-e2e-13m2',
})
.expect(200);
// 옛 토큰(rst=passwordChange) — 가드는 세션 표의 지금 값(twoFactorSetup)을 본다
const blocked = await request(server)
.get('/api/mailboxes')
.set('Cookie', cookies)
.expect(403);
expect(body(blocked).error?.code).toBe('AUTH_010');
const me = await request(server)
.get('/api/auth/me')
.set('Cookie', cookies)
.expect(200);
expect(body(me).data).toMatchObject({ restriction: 'twoFactorSetup' });
// 켜기 단계의 경로는 연다
await request(server)
.get('/api/two-factor')
.set('Cookie', cookies)
.expect(200);
} finally {
expiredPasswords.delete(ME);
}
});
it('2단계를 켜면 요청 세션의 제한만 풀리고, twoFactorSetup 이 붙은 다른 세션은 끊긴다 (리뷰 M1)', async () => {
const ME = 'policy.two@e2e.arasystem.test';
await settle({
twoFactor: { required: true, startDate: kstDateString(new Date()) },
});
// 같은 직원의 두 세션 — 둘 다 2단계를 거치지 않았다(비밀번호만 아는 다른 사람의 세션일 수 있다)
const mine = cookiesOf(await loginAs(ME, '198.18.13.7').expect(200));
const other = cookiesOf(await loginAs(ME, '198.18.13.8').expect(200));
try {
const enrolled = await request(server)
.post('/api/two-factor/enrollments')
.set('Cookie', mine)
.expect(201);
const { enrollmentId, secret } = dataOf<{
enrollmentId: string;
secret: string;
}>(enrolled);
const activated = await request(server)
.post('/api/two-factor/activation')
.set('Cookie', mine)
.send({
enrollmentId,
otpCode: otpCodeOf(decodeBase32(secret)),
currentPassword: CORRECT_PASSWORD,
})
.expect(200);
expect(body(activated).data).toMatchObject({
isEnabled: true,
isRequired: true,
deadline: null,
});
// 켠 세션 — 옛 토큰으로도 제한이 풀렸다
const me = await request(server)
.get('/api/auth/me')
.set('Cookie', mine)
.expect(200);
expect(body(me).data).toMatchObject(UNRESTRICTED);
await request(server)
.get('/api/mailboxes')
.set('Cookie', mine)
.expect(200);
// 다른 세션 — 2단계 없이 열리지 않고 끊겼다
const cut = await request(server)
.get('/api/mailboxes')
.set('Cookie', other)
.expect(401);
expect(body(cut).error?.code).toBe('AUTH_001');
await request(server)
.post('/api/auth/refresh')
.set('Cookie', other)
.expect(401);
} finally {
twoFactorUrls.delete(ME);
}
});
it('잠긴 계정(2단계 켜짐 — 엔진이 2단계를 먼저 봐 402)의 맞는 비밀번호도 틀린 비밀번호와 같은 401 AUTH_004 (실사용 시험)', async () => {
const ME = 'locked.otp@e2e.arasystem.test';
const secret = randomBytes(20);
twoFactorUrls.set(
ME,
buildOtpauthUrl({
issuer: 'ARA SYSTEM',
account: ME,
secret: encodeBase32(secret),
}),
);
lockedAccounts.add(ME);
try {
const right = await loginAs(ME, '198.18.13.9').expect(401);
const withCode = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.13.10')
.send({
email: ME,
password: CORRECT_PASSWORD,
otpCode: otpCodeOf(secret),
})
.expect(401);
const wrong = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.13.11')
.send({ email: ME, password: 'wrong-password-for-e2e' })
.expect(401);
// 셋 다 같은 코드 · 문구 — 맞는 비밀번호인지 바깥에서 가를 수 없다
for (const res of [right, withCode, wrong]) {
expect(body(res).error?.code).toBe('AUTH_004');
expect(body(res).error?.message).toBe(body(wrong).error?.message);
expect(res.headers['set-cookie']).toBeUndefined();
}
} finally {
lockedAccounts.delete(ME);
twoFactorUrls.delete(ME);
}
});
it('필수인 직원은 2단계 인증을 끌 수 없다 — 400 VAL_001 (엔진에 묻지 않는다)', async () => {
const ME = 'policy.on@e2e.arasystem.test';
// 켠 직원으로 만든다 — 대역 엔진에 바로 넣고 비밀번호 + 코드로 로그인
const secret = randomBytes(20);
twoFactorUrls.set(
ME,
buildOtpauthUrl({
issuer: 'ARA SYSTEM',
account: ME,
secret: encodeBase32(secret),
}),
);
try {
await settle({
twoFactor: { required: true, startDate: kstDateString(new Date()) },
});
const res = await request(server)
.post('/api/auth/login')
.set('X-Forwarded-For', '198.18.13.5')
.send({
email: ME,
password: CORRECT_PASSWORD,
otpCode: otpCodeOf(secret),
})
.expect(200);
// 켠 직원은 제한 · 유예가 없다
expect(body(res).data).toMatchObject({
status: 'signedIn',
...UNRESTRICTED,
});
const off = await request(server)
.post('/api/two-factor/deactivation')
.set('Cookie', cookiesOf(res))
.send({
currentPassword: CORRECT_PASSWORD,
otpCode: otpCodeOf(secret, 1),
})
.expect(400);
expect(body(off).error?.code).toBe('VAL_001');
expect(twoFactorUrls.has(ME)).toBe(true);
} finally {
twoFactorUrls.delete(ME);
}
});
it('문의 연락처 — 로그인 없이 { text }, 정하지 않았으면 null', async () => {
const none = await request(server)
.get('/api/support-contact')
.expect(200);
expect(body(none).data).toEqual({ text: null });
await settle({ supportContact: 'IT 담당 홍길동 · 02-000-0000' });
const set = await request(server).get('/api/support-contact').expect(200);
expect(body(set).data).toEqual({ text: 'IT 담당 홍길동 · 02-000-0000' });
});
});
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);
});
});
});