generated from ttipo/project-base
0090a161ec
14단계 실사용 시험(2026-11-06)에서 나온 것을 고친다. - 1만 통 메일함 안 검색이 1.1초였다. 계획기가 메일함만 담은 GIN(메일함 id 가 계정마다 같은 꼴이라 posting 이 회사 전체)으로 그 메일함 줄을 다 읽고 묶음 배열을 줄마다 풀었다. 직원 · 묶음 · 메일함을 한 색인에 담은 GIN 하나로 바꾸고 옛 GIN 둘을 지운다(마이그레이션 SearchIndexUserGramsMailboxes — 큰 메일함 15ms · 작은 메일함 1ms · 전체 15ms) - 한 글자 낱말뿐이면 본문을 찾지 않는데 본문 글자를 강조했다 — 본문 강조는 본문을 찾은 두 글자 이상 낱말만 - 답장의 결과 미리 보기가 원문 인용 조각이었다 — 인용 줄 밖의 맞은 곳을 먼저, 인용 안에서만 맞고 다른 칸에서도 맞았으면 본문 첫머리, 인용 조각은 줄 앞 `>` 를 뗀다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
9789 lines
374 KiB
TypeScript
9789 lines
374 KiB
TypeScript
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 type { SearchScope } from '@/modules/mail-search/mail-search.types';
|
||
import { MailSearchIndexRepository } from '@/modules/mail-search/mail-search-index.repository';
|
||
import { PLANNER_SETTINGS } from '@/modules/mail-search/mail-search-query.service';
|
||
import {
|
||
INDEX_FORMAT,
|
||
SEARCH_CHECK_ROWS_LIMIT,
|
||
} from '@/modules/mail-search/mail-search.constants';
|
||
import { toIndexRow } from '@/modules/mail-search/index-document';
|
||
import { planSearch } from '@/modules/mail-search/search-plan';
|
||
import { candidatesSql } from '@/modules/mail-search/search-sql';
|
||
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';
|
||
/** 대역 엔진에 없는 메일 (14단계 3차 — 같은 대화의 404) */
|
||
const MISSING_MESSAGE = 'nosuchmail';
|
||
|
||
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 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,
|
||
};
|
||
}
|
||
|
||
/** 대역 엔진이 색인 검색에서 빼는 메일함 (휴지통 · 스팸메일함 역할 — 14단계 2차) */
|
||
const SEARCH_EXCLUDED_MAILBOX = 'idxtrash';
|
||
|
||
/** 색인 문서의 주소 → 요약의 주소 (주소가 없는 것은 뺀다) */
|
||
function mailAddressesOf(
|
||
addresses: readonly { name: string | null; email: string | null }[],
|
||
): { name: string | null; email: string }[] {
|
||
return addresses.flatMap(({ name, email }) =>
|
||
email === null ? [] : [{ name, email }],
|
||
);
|
||
}
|
||
|
||
/** 대역 엔진의 요약 — 색인 문서와 같은 메일 (14단계 2차 — 색인으로 찾은 쪽의 요약은 엔진에서 읽는다) */
|
||
function summaryOfDocument(document: IndexDocument): MessageSummary {
|
||
return {
|
||
id: document.id,
|
||
threadId: document.threadId ?? document.id,
|
||
mailboxIds: [...document.mailboxIds],
|
||
from: mailAddressesOf(document.from)[0] ?? null,
|
||
to: mailAddressesOf(document.to),
|
||
subject: document.subject,
|
||
preview: document.preview,
|
||
receivedAt: new Date(document.receivedAt).toISOString(),
|
||
isRead: document.keywords.includes('$seen'),
|
||
isFlagged: document.keywords.includes('$flagged'),
|
||
hasAttachment: document.hasAttachment,
|
||
size: document.size,
|
||
pendingSend: null,
|
||
};
|
||
}
|
||
|
||
/** 목록 응답의 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 }[] = [];
|
||
/** 14단계 2차 — 대역 엔진이 받은 요약 요청 (색인으로 찾은 쪽) */
|
||
const summaryRequests: { user: string; ids: readonly string[] }[] = [];
|
||
/** 14단계 3차 — 대역 엔진이 받은 대화 요청 (엔진의 스레드 — 색인 전) */
|
||
const engineConversations: {
|
||
user: string;
|
||
emailId: string;
|
||
page: { limit: number; cursor?: string };
|
||
}[] = [];
|
||
const engineConversationLists: {
|
||
user: string;
|
||
query: { mailboxId: string; limit: number; cursor?: string };
|
||
}[] = [];
|
||
/** 14단계 3차 리뷰 L6 — 대역 엔진의 붙잡힌 메일(보내기 취소 · 예약을 기다리는) id 와 그것을 함께 읽은 요약 요청 */
|
||
const fakeHeldIds = new Set<string>();
|
||
const heldSummaryRequests: { user: string; ids: readonly string[] }[] = [];
|
||
/** 대역 엔진이 받은 여러 통 바꾸기 · 휴지통에서 영구 삭제 */
|
||
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,
|
||
});
|
||
},
|
||
// 14단계 2차 — 색인 검색의 범위 · 찾은 쪽의 요약 (엔진 쪽 판단은 jmap-mail.provider.spec.ts 가 본다)
|
||
searchScope: (
|
||
_user: string,
|
||
mailboxId?: string,
|
||
): Promise<SearchScope> => {
|
||
if (mailboxId === MISSING_MAILBOX) {
|
||
return Promise.reject(
|
||
new BusinessException('RES_001', {
|
||
context: { reason: 'e2e-mailbox-not-found' },
|
||
}),
|
||
);
|
||
}
|
||
return Promise.resolve(
|
||
mailboxId === undefined
|
||
? {
|
||
mailboxId: null,
|
||
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||
excludedKeywords: ['$ara-sending'],
|
||
}
|
||
: {
|
||
mailboxId,
|
||
excludedMailboxIds: [],
|
||
excludedKeywords: ['$ara-sending'],
|
||
},
|
||
);
|
||
},
|
||
// 14단계 3차 — 대화의 범위(늘 휴지통을 뺀다) · 엔진의 스레드로 본 대화(색인 전 — 받은 것만 적는다)
|
||
conversationScope: (
|
||
_user: string,
|
||
mailboxId?: string,
|
||
): Promise<SearchScope> =>
|
||
mailboxId === MISSING_MAILBOX
|
||
? Promise.reject(
|
||
new BusinessException('RES_001', {
|
||
context: { reason: 'e2e-mailbox-not-found' },
|
||
}),
|
||
)
|
||
: Promise.resolve({
|
||
mailboxId: mailboxId ?? null,
|
||
excludedMailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||
excludedKeywords: ['$ara-sending'],
|
||
}),
|
||
getConversation: (
|
||
user: string,
|
||
emailId: string,
|
||
page: { limit: number; cursor?: string },
|
||
) => {
|
||
engineConversations.push({ user, emailId, page });
|
||
return emailId === MISSING_MESSAGE
|
||
? Promise.reject(
|
||
new BusinessException('RES_001', {
|
||
context: { reason: 'e2e-message-not-found' },
|
||
}),
|
||
)
|
||
: Promise.resolve({
|
||
items: [],
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: 0,
|
||
});
|
||
},
|
||
listConversations: (
|
||
user: string,
|
||
query: { mailboxId: string; limit: number; cursor?: string },
|
||
) => {
|
||
engineConversationLists.push({ user, query });
|
||
return query.mailboxId === MISSING_MAILBOX
|
||
? Promise.reject(
|
||
new BusinessException('RES_001', {
|
||
context: { reason: 'e2e-mailbox-not-found' },
|
||
}),
|
||
)
|
||
: Promise.resolve({
|
||
items: [],
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: 0,
|
||
});
|
||
},
|
||
getSummaries: (
|
||
user: string,
|
||
ids: readonly string[],
|
||
): Promise<MessageSummary[]> => {
|
||
summaryRequests.push({ user, ids });
|
||
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
|
||
const byId = new Map(emails.map((email) => [email.id, email]));
|
||
return Promise.resolve(
|
||
ids.flatMap((id) => {
|
||
const email = byId.get(id);
|
||
return email ? [summaryOfDocument(email)] : [];
|
||
}),
|
||
);
|
||
},
|
||
getSummariesWithHeld: (user: string, ids: readonly string[]) => {
|
||
heldSummaryRequests.push({ user, ids });
|
||
const emails = fakeIndexSource.mailboxes.get(user)?.emails ?? [];
|
||
const byId = new Map(emails.map((email) => [email.id, email]));
|
||
return Promise.resolve({
|
||
summaries: ids.flatMap((id) => {
|
||
const email = byId.get(id);
|
||
return email ? [summaryOfDocument(email)] : [];
|
||
}),
|
||
heldIds: new Set(fakeHeldIds),
|
||
});
|
||
},
|
||
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',
|
||
groupByConversation: false,
|
||
},
|
||
});
|
||
});
|
||
|
||
it('PATCH 는 보낸 칸만 바꾸고 전체를 돌려준다 — 다시 읽어도 같고 DB 에 직원마다 한 줄', async () => {
|
||
const first = await patchSettings(ME, {
|
||
externalImages: 'always',
|
||
}).expect(200);
|
||
expect(dataOf<unknown>(first)).toEqual({
|
||
undoSendSeconds: 10,
|
||
externalImages: 'always',
|
||
groupByConversation: false,
|
||
});
|
||
|
||
const second = await patchSettings(ME, { undoSendSeconds: 30 }).expect(
|
||
200,
|
||
);
|
||
expect(dataOf<unknown>(second)).toEqual({
|
||
undoSendSeconds: 30,
|
||
externalImages: 'always',
|
||
groupByConversation: false,
|
||
});
|
||
|
||
const read = await request(server)
|
||
.get('/api/mail-settings')
|
||
.set('Cookie', cookieOf(ME))
|
||
.expect(200);
|
||
expect(dataOf<unknown>(read)).toEqual({
|
||
undoSendSeconds: 30,
|
||
externalImages: 'always',
|
||
groupByConversation: false,
|
||
});
|
||
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('대화로 묶어 보기(14단계) — 처음 끔, PATCH 로 켜고 끄며 다른 칸은 그대로 · DB 의 열에 저장', async () => {
|
||
const GROUP = 'settings.group@e2e.arasystem.test';
|
||
const on = await patchSettings(GROUP, {
|
||
groupByConversation: true,
|
||
}).expect(200);
|
||
expect(dataOf<unknown>(on)).toEqual({
|
||
undoSendSeconds: 10,
|
||
externalImages: 'ask',
|
||
groupByConversation: true,
|
||
});
|
||
const read = await request(server)
|
||
.get('/api/mail-settings')
|
||
.set('Cookie', cookieOf(GROUP))
|
||
.expect(200);
|
||
expect(dataOf<{ groupByConversation: boolean }>(read)).toMatchObject({
|
||
groupByConversation: true,
|
||
});
|
||
const [row]: { group_by_conversation: boolean }[] =
|
||
await dataSource.query(
|
||
'SELECT group_by_conversation FROM mail_settings WHERE user_email = $1',
|
||
[GROUP],
|
||
);
|
||
expect(row).toEqual({ group_by_conversation: true });
|
||
const off = await patchSettings(GROUP, {
|
||
groupByConversation: false,
|
||
undoSendSeconds: 5,
|
||
}).expect(200);
|
||
expect(dataOf<unknown>(off)).toEqual({
|
||
undoSendSeconds: 5,
|
||
externalImages: 'ask',
|
||
groupByConversation: false,
|
||
});
|
||
});
|
||
|
||
it.each<[string, object]>([
|
||
['빈 본문(하나 이상)', {}],
|
||
['고를 수 없는 초', { undoSendSeconds: 7 }],
|
||
['글로 쓴 초', { undoSendSeconds: '10' }],
|
||
['null 초', { undoSendSeconds: null }],
|
||
['모르는 외부 이미지 값', { externalImages: 'never' }],
|
||
['null 외부 이미지', { externalImages: null }],
|
||
['글로 쓴 대화로 묶기', { groupByConversation: 'true' }],
|
||
['null 대화로 묶기', { groupByConversation: 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 이 & 를 & 로 쓰지 않는 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('14단계 2차 — 색인으로 찾기 · 조건 · 강조 (GET /api/messages · GET /api/messages/{id})', () => {
|
||
const ME = 'search.query@e2e.arasystem.test';
|
||
const SEOJUN = { name: '박서준', email: 'seojun.park@e2e.arasystem.test' };
|
||
const DOCUMENTS = [
|
||
indexDocument({
|
||
id: 'm-meeting',
|
||
subject: '회의 일정',
|
||
bodyText: '내일 회의는 3층 회의실에서 합니다',
|
||
preview: '내일 회의는 3층 회의실에서 합니다',
|
||
from: [SEOJUN],
|
||
to: [{ name: '김아라', email: ME }],
|
||
receivedAt: '2026-11-05T01:00:00Z',
|
||
}),
|
||
indexDocument({
|
||
id: 'm-contract',
|
||
subject: '[공지] 11월 계약서',
|
||
bodyText: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||
preview: '계약서를 보내 드립니다.',
|
||
attachmentNames: ['계약서.PDF'],
|
||
hasAttachment: true,
|
||
cc: [{ name: 'Partner', email: 'partner@example.com' }],
|
||
receivedAt: '2026-11-04T01:00:00Z',
|
||
}),
|
||
indexDocument({
|
||
id: 'm-renewal',
|
||
subject: '회의록',
|
||
bodyText: '계약 갱신 회의 내용을 정리했습니다',
|
||
preview: '계약 갱신 회의 내용을 정리했습니다',
|
||
receivedAt: '2026-11-03T01:00:00Z',
|
||
}),
|
||
indexDocument({
|
||
id: 'm-floor',
|
||
subject: '7층 공사 안내',
|
||
bodyText: '공사 기간 동안 엘리베이터를 쓸 수 없습니다',
|
||
receivedAt: '2026-11-02T01:00:00Z',
|
||
}),
|
||
// 휴지통에만 든 메일 — 메일함을 정하지 않은 검색에서 빠진다
|
||
indexDocument({
|
||
id: 'm-trash',
|
||
subject: '지난 계약 해지',
|
||
bodyText: '계약 해지 통보',
|
||
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||
receivedAt: '2026-11-01T01:00:00Z',
|
||
}),
|
||
// 답장 — 내 글 아래에 원문 인용(> 줄)이 있다 (미리 보기 고르기 — 2026-11-06 사용자 관점 점검)
|
||
indexDocument({
|
||
id: 'm-reply',
|
||
subject: 'RE: 해오름 프로젝트 안내',
|
||
bodyText:
|
||
'네, 받았습니다. 금요일까지 확인하겠습니다.\n\n> 원본 메일\n> 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||
preview:
|
||
'네, 받았습니다. 금요일까지 확인하겠습니다. > 원본 메일 > 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||
receivedAt: '2026-10-30T01:00:00Z',
|
||
}),
|
||
];
|
||
|
||
interface ListItem {
|
||
id: string;
|
||
subject: string;
|
||
searchHighlight: {
|
||
subject: [number, number][];
|
||
preview: { text: string; ranges: [number, number][] };
|
||
} | null;
|
||
}
|
||
|
||
const list = (params: Record<string, string | number>) =>
|
||
request(server)
|
||
.get('/api/messages')
|
||
.query(params)
|
||
.set('Cookie', cookieOf(ME));
|
||
const idsOf = async (params: Record<string, string | number>) =>
|
||
dataOf<Page<ListItem>>(await list(params).expect(200)).items.map(
|
||
(item) => item.id,
|
||
);
|
||
const setStatus = (status: 'ready' | 'building') =>
|
||
dataSource.query(
|
||
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
|
||
[ME, status],
|
||
);
|
||
|
||
beforeAll(async () => {
|
||
// 상태 줄은 준비 상태를 물을 때 생긴다 — 채우기로 ready (색인 판은 지금 판)
|
||
await request(server)
|
||
.get('/api/search-index')
|
||
.set('Cookie', cookieOf(ME))
|
||
.expect(200);
|
||
fakeIndexSource.mailboxes.set(ME, { state: 'q1', emails: DOCUMENTS });
|
||
const build = app.get(MailSearchBuildService);
|
||
expect(await build.build(ME, Date.now() + 120_000)).toBe('ready');
|
||
});
|
||
|
||
afterAll(async () => {
|
||
fakeIndexSource.mailboxes.delete(ME);
|
||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||
await dataSource.query(`DELETE FROM "${table}" WHERE user_email = $1`, [
|
||
ME,
|
||
]);
|
||
}
|
||
});
|
||
|
||
it('색인이 준비되면 부분 일치로 찾는다 — 휴지통 · 스팸메일함에만 든 메일은 빼고 받은 시각 최신부터, 요약은 엔진에서 · 첫 쪽의 total', async () => {
|
||
const queriesBefore = listQueries.length;
|
||
|
||
const res = await list({ q: '계약' }).expect(200);
|
||
const page = dataOf<Page<ListItem>>(res);
|
||
|
||
expect(page.items.map((item) => item.id)).toEqual([
|
||
'm-contract',
|
||
'm-renewal',
|
||
]);
|
||
expect(page.pageInfo).toEqual({
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: 2,
|
||
});
|
||
// 엔진의 목록 검색은 묻지 않았다 — 찾은 쪽의 요약만
|
||
expect(listQueries.length).toBe(queriesBefore);
|
||
expect(summaryRequests.at(-1)).toEqual({
|
||
user: ME,
|
||
ids: ['m-contract', 'm-renewal'],
|
||
});
|
||
});
|
||
|
||
it('강조 — 제목의 맞은 곳 · 본문에서 맞은 곳 둘레의 미리 보기와 그 안의 맞은 곳 (UTF-16 [시작, 끝))', async () => {
|
||
const [contract] = dataOf<Page<ListItem>>(
|
||
await list({ q: '계약 갱신' }).expect(200),
|
||
).items;
|
||
|
||
expect(contract.id).toBe('m-contract');
|
||
expect(contract.searchHighlight).toEqual({
|
||
subject: [[9, 11]],
|
||
preview: {
|
||
text: '계약서를 보내 드립니다. 갱신 일정은 다음 주입니다.',
|
||
ranges: [
|
||
[0, 2],
|
||
[14, 16],
|
||
],
|
||
},
|
||
});
|
||
});
|
||
|
||
it('조건 — 보낸 사람 · 받는 사람(참조 포함) · 제목 · 내용 · 기간 · 첨부, 검색어 없이도 · 검색어와 함께면 모두 맞아야', async () => {
|
||
expect(await idsOf({ from: '박서준' })).toEqual(['m-meeting']);
|
||
expect(await idsOf({ from: 'seojun' })).toEqual(['m-meeting']);
|
||
expect(await idsOf({ to: 'partner' })).toEqual(['m-contract']);
|
||
expect(await idsOf({ subject: '회의' })).toEqual([
|
||
'm-meeting',
|
||
'm-renewal',
|
||
]);
|
||
expect(await idsOf({ body: '회의실' })).toEqual(['m-meeting']);
|
||
expect(await idsOf({ hasAttachment: 'true' })).toEqual(['m-contract']);
|
||
expect(
|
||
await idsOf({
|
||
after: '2026-11-03T00:00:00.000Z',
|
||
before: '2026-11-04T01:00:00.000Z',
|
||
}),
|
||
).toEqual(['m-renewal']);
|
||
expect(await idsOf({ q: '회의', from: '이도윤' })).toEqual(['m-renewal']);
|
||
expect(await idsOf({ q: '"갱신 일정"' })).toEqual(['m-contract']);
|
||
// 메일함 범위
|
||
expect(await idsOf({ mailboxId: 'inbox', q: '계약' })).toEqual([
|
||
'm-contract',
|
||
'm-renewal',
|
||
]);
|
||
expect(
|
||
await idsOf({ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' }),
|
||
).toEqual(['m-trash']);
|
||
});
|
||
|
||
it('한 글자 낱말뿐이면 제목 · 사람 · 첨부 이름에서만 — 본문에만 있는 메일은 빠진다', async () => {
|
||
// '층' 은 m-floor 의 제목 · m-meeting 의 본문에 있다
|
||
expect(await idsOf({ q: '층' })).toEqual(['m-floor']);
|
||
// 두 글자 넘는 낱말과 함께면 본문에서도 확인한다
|
||
expect(await idsOf({ q: '층 회의실' })).toEqual(['m-meeting']);
|
||
// 본문을 찾지 않았으니 미리 보기에도 강조하지 않는다 — '공' 은 m-floor 의 제목과 본문에 있다 (2026-11-06 실사용 시험)
|
||
const found = dataOf<Page<ListItem>>(
|
||
await list({ q: '공' }).expect(200),
|
||
).items;
|
||
expect(found.map((item) => item.id)).toEqual(['m-contract', 'm-floor']);
|
||
expect(found[1].searchHighlight).toEqual({
|
||
subject: [[3, 4]],
|
||
preview: { text: '', ranges: [] },
|
||
});
|
||
});
|
||
|
||
it('미리 보기는 답장의 원문 인용(> 줄)을 피한다 — 인용 안에서만 맞고 제목에서도 맞으면 인용 앞의 글, 인용에만 있는 말은 > 를 뗀 인용 조각 (2026-11-06 사용자 관점 점검)', async () => {
|
||
const [byTitle] = dataOf<Page<ListItem>>(
|
||
await list({ q: '해오름' }).expect(200),
|
||
).items;
|
||
expect(byTitle.id).toBe('m-reply');
|
||
expect(byTitle.searchHighlight).toEqual({
|
||
subject: [[4, 7]],
|
||
preview: {
|
||
text: '네, 받았습니다. 금요일까지 확인하겠습니다.…',
|
||
ranges: [],
|
||
},
|
||
});
|
||
|
||
const [byQuote] = dataOf<Page<ListItem>>(
|
||
await list({ q: 'R9A51' }).expect(200),
|
||
).items;
|
||
expect(byQuote.id).toBe('m-reply');
|
||
expect(byQuote.searchHighlight?.preview).toEqual({
|
||
text: '…원본 메일 해오름 프로젝트 R9A51 자료를 보냅니다.',
|
||
ranges: [[16, 21]],
|
||
});
|
||
});
|
||
|
||
it('메일함 범위의 후보는 찾기 GIN 하나로 — 주소 · 묶음 · 메일함을 색인 조건으로 맞추고, 메일함만으로 줄을 모아 묶음을 줄마다 풀지 않는다 (실제 PostgreSQL — 2026-11-06 실사용 시험)', async () => {
|
||
// 메일함만 담은 GIN 은 없다 — 있으면 계획기가 그것만으로 메일함의 줄(회사 전체 posting)을 읽고 묶음 배열을 줄마다 풀 수 있다
|
||
const gins: { indexname: string }[] = await dataSource.query(
|
||
`SELECT indexname FROM pg_indexes WHERE tablename = 'mail_search_messages' AND indexdef ILIKE '%USING gin%' ORDER BY indexname`,
|
||
);
|
||
expect(gins.map((row) => row.indexname)).toEqual([
|
||
'IDX_mail_search_messages_refs',
|
||
'IDX_mail_search_messages_user_grams_mailboxes',
|
||
]);
|
||
|
||
interface PlanNode {
|
||
'Index Name'?: string;
|
||
'Index Cond'?: string;
|
||
Filter?: string;
|
||
'Recheck Cond'?: string;
|
||
Plans?: PlanNode[];
|
||
}
|
||
const nodesOf = (node: PlanNode): PlanNode[] => [
|
||
node,
|
||
...(node.Plans ?? []).flatMap(nodesOf),
|
||
];
|
||
const scope: SearchScope = {
|
||
mailboxId: 'inbox',
|
||
excludedMailboxIds: [],
|
||
excludedKeywords: ['$ara-sending'],
|
||
};
|
||
for (const text of ['계약', '계약서 갱신']) {
|
||
const query = candidatesSql(
|
||
ME,
|
||
scope,
|
||
planSearch({ text, hasAttachment: false }),
|
||
null,
|
||
SEARCH_CHECK_ROWS_LIMIT + 1,
|
||
);
|
||
// 서비스와 같은 계획기 설정(SET LOCAL — 트랜잭션 안)으로
|
||
const rows: { 'QUERY PLAN': unknown }[] = await dataSource.transaction(
|
||
async (manager) => {
|
||
for (const setting of PLANNER_SETTINGS.bitmap) {
|
||
await manager.query(setting);
|
||
}
|
||
return manager.query(
|
||
`EXPLAIN (FORMAT JSON) ${query.sql}`,
|
||
query.params,
|
||
);
|
||
},
|
||
);
|
||
const raw = rows[0]['QUERY PLAN'];
|
||
const [explained] = (
|
||
typeof raw === 'string' ? JSON.parse(raw) : raw
|
||
) as { Plan: PlanNode }[];
|
||
const nodes = nodesOf(explained.Plan);
|
||
const scans = nodes.filter((node) => node['Index Name']);
|
||
expect(scans.map((node) => node['Index Name'])).toEqual([
|
||
'IDX_mail_search_messages_user_grams_mailboxes',
|
||
]);
|
||
expect(scans[0]['Index Cond']).toContain('mailbox_ids');
|
||
expect(scans[0]['Index Cond']).toContain('grams');
|
||
// 묶음은 줄마다 보지 않는다 — 걸러 내기(Filter)에 묶음이 없다
|
||
expect(nodes.map((node) => node.Filter ?? '').join(' ')).not.toContain(
|
||
'grams',
|
||
);
|
||
}
|
||
});
|
||
|
||
it('쪽 넘기기 — 다음 쪽은 커서의 자리부터, 다음 쪽의 total 은 null · 다른 검색의 커서는 400 VAL_001', async () => {
|
||
const first = dataOf<Page<ListItem>>(
|
||
await list({ q: '계약', limit: 1 }).expect(200),
|
||
);
|
||
expect(first.items.map((item) => item.id)).toEqual(['m-contract']);
|
||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
||
const cursor = first.pageInfo.nextCursor as string;
|
||
|
||
const second = dataOf<Page<ListItem>>(
|
||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||
);
|
||
expect(second.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||
expect(second.pageInfo).toEqual({
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: null,
|
||
});
|
||
|
||
const others: Record<string, string>[] = [
|
||
{ q: '견적', cursor },
|
||
{ q: '계약', from: '이도윤', cursor },
|
||
{ q: '계약', mailboxId: 'inbox', cursor },
|
||
];
|
||
for (const params of others) {
|
||
const res = await list(params).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
});
|
||
|
||
it('쪽을 넘기는 중에는 처음 방식대로 — 색인 커서는 다시 채우기에 들어가도 색인으로, 준비 중의 새 검색은 엔진 방식(조건도 넘긴다 · 강조 없음)', async () => {
|
||
const cursor = dataOf<Page<ListItem>>(
|
||
await list({ q: '계약', limit: 1 }).expect(200),
|
||
).pageInfo.nextCursor as string;
|
||
await setStatus('building');
|
||
try {
|
||
const queriesBefore = listQueries.length;
|
||
const next = dataOf<Page<ListItem>>(
|
||
await list({ q: '계약', limit: 1, cursor }).expect(200),
|
||
);
|
||
expect(next.items.map((item) => item.id)).toEqual(['m-renewal']);
|
||
expect(listQueries.length).toBe(queriesBefore);
|
||
|
||
const engine = dataOf<Page<ListItem>>(
|
||
await list({
|
||
q: ' 계약 ',
|
||
from: '이도윤',
|
||
hasAttachment: 'true',
|
||
}).expect(200),
|
||
);
|
||
expect(engine.items).toEqual([]);
|
||
expect(listQueries.slice(queriesBefore)).toEqual([
|
||
{
|
||
user: ME,
|
||
query: {
|
||
mailboxId: undefined,
|
||
limit: 20,
|
||
cursor: undefined,
|
||
text: '계약',
|
||
conditions: { from: '이도윤', hasAttachment: true },
|
||
},
|
||
},
|
||
]);
|
||
} finally {
|
||
await setStatus('ready');
|
||
}
|
||
});
|
||
|
||
it('다른 직원의 줄은 섞이지 않는다 — 같은 낱말 · 같은 메일함 id 여도 (두 글자 · 확인 · 한 글자 · 조건만 · 메일함 범위 — 리뷰 L6)', async () => {
|
||
const OTHER = 'search.neighbor@e2e.arasystem.test';
|
||
await request(server)
|
||
.get('/api/search-index')
|
||
.set('Cookie', cookieOf(OTHER))
|
||
.expect(200);
|
||
// 엔진의 메일함 id 는 계정마다 같은 꼴이다(inbox) — 글도 ME 의 것과 같게
|
||
fakeIndexSource.mailboxes.set(OTHER, {
|
||
state: 'n1',
|
||
emails: DOCUMENTS.map((document) => ({
|
||
...document,
|
||
id: `x-${document.id}`,
|
||
})),
|
||
});
|
||
try {
|
||
const build = app.get(MailSearchBuildService);
|
||
expect(await build.build(OTHER, Date.now() + 120_000)).toBe('ready');
|
||
|
||
const searches: Record<string, string>[] = [
|
||
{ q: '계약' },
|
||
{ q: '계약서' },
|
||
{ q: '층' },
|
||
{ subject: '회의' },
|
||
{ hasAttachment: 'true' },
|
||
{ after: '2026-11-01T00:00:00.000Z' },
|
||
{ mailboxId: 'inbox', q: '회의' },
|
||
{ mailboxId: SEARCH_EXCLUDED_MAILBOX, q: '계약' },
|
||
];
|
||
for (const params of searches) {
|
||
const before = summaryRequests.length;
|
||
const ids = await idsOf(params);
|
||
expect(ids.length).toBeGreaterThan(0);
|
||
// 색인이 찾은 id 그대로(엔진에 요약을 묻기 전) — 다른 직원의 줄이 없다
|
||
const asked = summaryRequests
|
||
.slice(before)
|
||
.flatMap((request) => [...request.ids]);
|
||
expect(asked.length).toBeGreaterThan(0);
|
||
expect(asked.filter((id) => id.startsWith('x-'))).toEqual([]);
|
||
}
|
||
} finally {
|
||
fakeIndexSource.mailboxes.delete(OTHER);
|
||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||
await dataSource.query(
|
||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||
[OTHER],
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('검색 글의 NUL 은 U+FFFD 로 바꿔 찾는다 — DB 오류(500)가 아니다, 엔진 방식도 (리뷰 L2)', async () => {
|
||
const NUL = String.fromCharCode(0);
|
||
expect(await idsOf({ q: `계약${NUL}` })).toEqual([
|
||
'm-contract',
|
||
'm-renewal',
|
||
]);
|
||
expect(await idsOf({ q: `계${NUL}약`, subject: `회${NUL}의` })).toEqual(
|
||
[],
|
||
);
|
||
await setStatus('building');
|
||
try {
|
||
const res = await list({ q: `계${NUL}약` }).expect(200);
|
||
expect(dataOf<Page<ListItem>>(res).items).toEqual([]);
|
||
} finally {
|
||
await setStatus('ready');
|
||
}
|
||
});
|
||
|
||
it('검색어와 조건 칸 넷의 낱말은 합쳐 8개까지 — 넘으면 400 VAL_001 (읽기 화면도 같다 — 리뷰 M2)', async () => {
|
||
const words = (prefix: string, count: number) =>
|
||
Array.from({ length: count }, (_, index) => `${prefix}${index}`).join(
|
||
' ',
|
||
);
|
||
await list({ q: words('q', 4), to: words('t', 4) }).expect(200);
|
||
const overLimit: Record<string, string>[] = [
|
||
{ q: words('q', 4), to: words('t', 5) },
|
||
{ from: words('f', 3), subject: words('s', 3), body: words('b', 3) },
|
||
];
|
||
for (const params of overLimit) {
|
||
const res = await list(params).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
const detail = await request(server)
|
||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||
.query({ q: words('q', 5), body: words('b', 4) })
|
||
.set('Cookie', cookieOf(ME))
|
||
.expect(400);
|
||
expect(body(detail).error?.code).toBe('VAL_001');
|
||
});
|
||
|
||
it('판을 되돌리면(서버가 뜰 때) building · 엔진 방식이 되고, 다시 채울 때 첫 묶음이 옛 판 줄을 지운다 — 실제 PostgreSQL (리뷰 M1)', async () => {
|
||
const RESET = 'search.reset@e2e.arasystem.test';
|
||
await request(server)
|
||
.get('/api/search-index')
|
||
.set('Cookie', cookieOf(RESET))
|
||
.expect(200);
|
||
fakeIndexSource.mailboxes.set(RESET, {
|
||
state: 'r1',
|
||
emails: DOCUMENTS.map((document) => ({
|
||
...document,
|
||
id: `r-${document.id}`,
|
||
})),
|
||
});
|
||
try {
|
||
const build = app.get(MailSearchBuildService);
|
||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||
// 옛 판으로 만든 줄처럼 — 판을 내리고 엔진에 없는 줄도 하나 남긴다
|
||
await dataSource.query(
|
||
`UPDATE mail_search_states SET index_format = $2 WHERE user_email = $1`,
|
||
[RESET, INDEX_FORMAT - 1],
|
||
);
|
||
await dataSource.query(
|
||
`UPDATE mail_search_messages SET fold_body = 'old-format' WHERE user_email = $1`,
|
||
[RESET],
|
||
);
|
||
|
||
// 판이 다르면 되돌리기 전에도 상태는 building (검색은 엔진 방식)
|
||
expect(
|
||
dataOf(
|
||
await request(server)
|
||
.get('/api/search-index')
|
||
.set('Cookie', cookieOf(RESET))
|
||
.expect(200),
|
||
),
|
||
).toEqual({ status: 'building', done: 0, total: null });
|
||
|
||
const repository = app.get(MailSearchIndexRepository);
|
||
expect(
|
||
await repository.resetOutdatedFormats(
|
||
`@${TEST_MAIL_SETTINGS.domain}`,
|
||
INDEX_FORMAT,
|
||
new Date(),
|
||
),
|
||
).toBeGreaterThanOrEqual(1);
|
||
const [state]: {
|
||
status: string;
|
||
index_format: number;
|
||
build_started_at: Date | null;
|
||
}[] = await dataSource.query(
|
||
'SELECT status, index_format, build_started_at FROM mail_search_states WHERE user_email = $1',
|
||
[RESET],
|
||
);
|
||
expect(state).toEqual({
|
||
status: 'building',
|
||
index_format: INDEX_FORMAT,
|
||
build_started_at: null,
|
||
});
|
||
|
||
expect(await build.build(RESET, Date.now() + 120_000)).toBe('ready');
|
||
const rows: { email_id: string; fold_body: string }[] =
|
||
await dataSource.query(
|
||
'SELECT email_id, fold_body FROM mail_search_messages WHERE user_email = $1 ORDER BY email_id',
|
||
[RESET],
|
||
);
|
||
expect(rows.map((row) => row.email_id)).toEqual(
|
||
DOCUMENTS.map((document) => `r-${document.id}`).sort(),
|
||
);
|
||
expect(rows.filter((row) => row.fold_body === 'old-format')).toEqual(
|
||
[],
|
||
);
|
||
} finally {
|
||
fakeIndexSource.mailboxes.delete(RESET);
|
||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||
await dataSource.query(
|
||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||
[RESET],
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('검색 칸의 검사 — 길이 · 낱말 수 · 기간 형식 · 첨부 값이 틀리면 400 VAL_001 (엔진 · 색인에 묻지 않는다)', async () => {
|
||
const before = summaryRequests.length;
|
||
const rejected: Record<string, string>[] = [
|
||
{ subject: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||
{
|
||
body: Array.from(
|
||
{ length: MAX_SEARCH_WORDS + 1 },
|
||
(_, index) => `w${index}`,
|
||
).join(' '),
|
||
},
|
||
{ after: '2026-11-01' },
|
||
{ before: '2026-11-01T00:00:00+09:00' },
|
||
{ hasAttachment: 'false' },
|
||
];
|
||
for (const params of rejected) {
|
||
const res = await list(params).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
expect(summaryRequests.length).toBe(before);
|
||
});
|
||
|
||
describe('읽기 화면의 강조 (GET /api/messages/{id} 의 q · subject · body)', () => {
|
||
interface ReadDetail {
|
||
subject: string;
|
||
htmlBody: string | null;
|
||
textBody: string | null;
|
||
imagesShown: boolean;
|
||
searchHighlight: {
|
||
subject: [number, number][];
|
||
attachments: Record<string, [number, number][]>;
|
||
textBody: [number, number][];
|
||
} | null;
|
||
}
|
||
const read = (query: Record<string, string>) =>
|
||
request(server)
|
||
.get(`/api/messages/${IMAGE_MESSAGE}`)
|
||
.query(query)
|
||
.set('Cookie', cookieOf(ME));
|
||
|
||
it('검색 쿼리가 없으면 searchHighlight 는 null', async () => {
|
||
const detail = dataOf<ReadDetail>(await read({}).expect(200));
|
||
expect(detail.searchHighlight).toBeNull();
|
||
expect(detail.htmlBody).not.toContain('<mark>');
|
||
});
|
||
|
||
it('검색어 — 본문의 글에 <mark>(정화한 뒤 — 태그 · 속성은 그대로) · textBody 의 위치, 보낸 사람 조건은 강조하지 않는다', async () => {
|
||
const detail = dataOf<ReadDetail>(
|
||
await read({ q: '안녕', from: '바깥' }).expect(200),
|
||
);
|
||
expect(detail.searchHighlight).toEqual({
|
||
subject: [],
|
||
attachments: {},
|
||
textBody: [[0, 2]],
|
||
});
|
||
expect(detail.htmlBody).toContain('<p><mark>안녕</mark>하세요</p>');
|
||
expect(detail.htmlBody).not.toContain('<mark>로고');
|
||
});
|
||
|
||
it('한 글자 낱말뿐이면 본문에는 강조하지 않는다 — 제목은 그대로 (2026-11-06 실사용 시험)', async () => {
|
||
// '든' 은 제목(그림이 든 메일)에, '안' 은 본문(안녕하세요)에 있다
|
||
const detail = dataOf<ReadDetail>(
|
||
await read({ q: '든 안' }).expect(200),
|
||
);
|
||
expect(detail.searchHighlight).toEqual({
|
||
subject: [[4, 5]],
|
||
attachments: {},
|
||
textBody: [],
|
||
});
|
||
expect(detail.htmlBody).not.toContain('<mark>');
|
||
});
|
||
|
||
it('images=show 와 함께 — 중계 주소로 바꾼 본문에 강조, 제목 조건은 제목에', async () => {
|
||
const detail = dataOf<ReadDetail>(
|
||
await read({ images: 'show', subject: '그림', q: '안녕' }).expect(
|
||
200,
|
||
),
|
||
);
|
||
expect(detail.imagesShown).toBe(true);
|
||
expect(detail.htmlBody).toContain('/api/mail-images/');
|
||
expect(detail.htmlBody).toContain('<mark>안녕</mark>');
|
||
expect(detail.searchHighlight?.subject).toEqual([[0, 2]]);
|
||
});
|
||
|
||
it('검색 칸은 목록과 같은 검사 · 목록에만 있는 조건(after · hasAttachment)은 받지 않는다 — 400 VAL_001', async () => {
|
||
const rejected: Record<string, string>[] = [
|
||
{ q: '가'.repeat(MAX_SEARCH_LENGTH + 1) },
|
||
{ after: '2026-11-01T00:00:00.000Z' },
|
||
{ before: '2026-11-30T15:00:00.000Z' },
|
||
{ hasAttachment: 'true' },
|
||
{ cursor: 'abc' },
|
||
{ limit: '20' },
|
||
{ mailboxId: 'inbox' },
|
||
];
|
||
for (const query of rejected) {
|
||
const res = await read(query).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
});
|
||
});
|
||
});
|
||
|
||
describe('14단계 3차 — 대화 키 · 같은 대화 · 대화로 묶은 목록 (실제 PostgreSQL)', () => {
|
||
const ME = 'thread.me@e2e.arasystem.test';
|
||
const OTHER = 'thread.other@e2e.arasystem.test';
|
||
const MINE = { name: '김아라', email: ME };
|
||
const at = (day: number, hour: number) => `2026-11-0${day}T0${hour}:00:00Z`;
|
||
/** 견적 대화 — 회신 · 답장 · 전달 말머리 · 겹친 Re · 꼬리표, 휴지통에만 든 답장 하나 */
|
||
const DOCUMENTS = [
|
||
indexDocument({
|
||
id: 't-orig',
|
||
subject: '견적 요청',
|
||
messageIds: ['orig@e2e.test'],
|
||
receivedAt: at(1, 1),
|
||
}),
|
||
indexDocument({
|
||
id: 't-re',
|
||
subject: 'Re: 견적 요청',
|
||
messageIds: ['re1@e2e.test'],
|
||
inReplyTo: ['orig@e2e.test'],
|
||
references: ['orig@e2e.test'],
|
||
receivedAt: at(1, 2),
|
||
}),
|
||
indexDocument({
|
||
id: 't-hoesin',
|
||
subject: '회신: 견적 요청',
|
||
messageIds: ['re2@e2e.test'],
|
||
inReplyTo: ['re1@e2e.test'],
|
||
references: ['orig@e2e.test', 're1@e2e.test'],
|
||
from: [MINE],
|
||
mailboxIds: ['sent'],
|
||
receivedAt: at(1, 3),
|
||
}),
|
||
indexDocument({
|
||
id: 't-fwd',
|
||
subject: 'FW: 전달: [외부] 견적 요청',
|
||
messageIds: ['fw@e2e.test'],
|
||
references: ['orig@e2e.test'],
|
||
receivedAt: at(1, 4),
|
||
}),
|
||
indexDocument({
|
||
id: 't-rere',
|
||
subject: 'RE: RE: Re[2]: 견적 요청',
|
||
messageIds: ['re3@e2e.test'],
|
||
inReplyTo: ['re2@e2e.test'],
|
||
keywords: [],
|
||
receivedAt: at(1, 5),
|
||
}),
|
||
indexDocument({
|
||
id: 't-trash',
|
||
subject: 'Re: 견적 요청',
|
||
messageIds: ['re4@e2e.test'],
|
||
inReplyTo: ['re3@e2e.test'],
|
||
mailboxIds: [SEARCH_EXCLUDED_MAILBOX],
|
||
receivedAt: at(1, 6),
|
||
}),
|
||
// 답장 관계는 있지만 제목이 다르다 — 따로
|
||
indexDocument({
|
||
id: 't-other',
|
||
subject: 'Re: 다른 일',
|
||
messageIds: ['other@e2e.test'],
|
||
inReplyTo: ['orig@e2e.test'],
|
||
receivedAt: at(1, 7),
|
||
}),
|
||
// Message-ID 가 없다 — 혼자
|
||
indexDocument({
|
||
id: 't-nomid',
|
||
subject: 'Re: 견적 요청',
|
||
messageIds: [],
|
||
inReplyTo: ['orig@e2e.test'],
|
||
receivedAt: at(1, 8),
|
||
}),
|
||
// 원본(lo@)보다 먼저 들어온 답장 둘 — 원본이 오면 합친다
|
||
indexDocument({
|
||
id: 't-late-r1',
|
||
subject: '답장: 회의록 공유',
|
||
messageIds: ['lr1@e2e.test'],
|
||
inReplyTo: ['lo@e2e.test'],
|
||
receivedAt: at(2, 1),
|
||
}),
|
||
indexDocument({
|
||
id: 't-late-r2',
|
||
subject: 'Re: 회의록 공유',
|
||
messageIds: ['lr2@e2e.test'],
|
||
inReplyTo: ['lo@e2e.test'],
|
||
receivedAt: at(2, 2),
|
||
}),
|
||
// 발송 중인 내부 초안 — 견적 대화에 이어지지만 목록 · 다른 메일의 대화에서는 빠진다
|
||
indexDocument({
|
||
id: 't-sending',
|
||
subject: 'Re: 견적 요청',
|
||
messageIds: ['re5@e2e.test'],
|
||
inReplyTo: ['re3@e2e.test'],
|
||
mailboxIds: ['drafts'],
|
||
keywords: ['$draft', '$ara-sending'],
|
||
receivedAt: at(2, 3),
|
||
}),
|
||
// 주소 없이 이름만 `대표 <ceo@…>` 꼴인 보낸 사람 — 참여자가 그 주소로 보이지 않는다 (리뷰 L3, 공지 메일함)
|
||
indexDocument({
|
||
id: 't-notice',
|
||
subject: '공지',
|
||
messageIds: ['notice@e2e.test'],
|
||
from: [{ name: '대표 <ceo@example.com>', email: null }],
|
||
mailboxIds: ['notice'],
|
||
receivedAt: at(3, 1),
|
||
}),
|
||
indexDocument({
|
||
id: 't-notice-re',
|
||
subject: 'Re: 공지',
|
||
messageIds: ['notice-re@e2e.test'],
|
||
inReplyTo: ['notice@e2e.test'],
|
||
mailboxIds: ['notice'],
|
||
receivedAt: at(3, 2),
|
||
}),
|
||
];
|
||
const LATE_ORIGINAL = indexDocument({
|
||
id: 't-late-orig',
|
||
subject: '회의록 공유',
|
||
messageIds: ['lo@e2e.test'],
|
||
receivedAt: at(1, 9),
|
||
});
|
||
|
||
const keysOf = async (user: string) =>
|
||
Object.fromEntries(
|
||
(
|
||
await dataSource.query<{ email_id: string; thread_key: string }[]>(
|
||
'SELECT email_id, thread_key FROM mail_search_messages WHERE user_email = $1',
|
||
[user],
|
||
)
|
||
).map((row) => [row.email_id, row.thread_key]),
|
||
);
|
||
const conversation = (
|
||
id: string,
|
||
query: Record<string, string | number> = {},
|
||
) =>
|
||
request(server)
|
||
.get(`/api/messages/${id}/conversation`)
|
||
.query(query)
|
||
.set('Cookie', cookieOf(ME));
|
||
const conversations = (query: Record<string, string | number>) =>
|
||
request(server)
|
||
.get('/api/conversations')
|
||
.query(query)
|
||
.set('Cookie', cookieOf(ME));
|
||
interface ConversationPage {
|
||
items: { id: string }[];
|
||
pageInfo: Page<unknown>['pageInfo'];
|
||
basis: string;
|
||
}
|
||
interface ConversationRow {
|
||
id: string;
|
||
latest: { id: string };
|
||
participants: { name: string | null; email: string }[];
|
||
messageCount: number;
|
||
messageIds: string[];
|
||
pendingSendIds: string[];
|
||
isRead: boolean;
|
||
isFlagged: boolean;
|
||
hasAttachment: boolean;
|
||
}
|
||
interface ConversationListPage {
|
||
items: ConversationRow[];
|
||
pageInfo: Page<unknown>['pageInfo'];
|
||
basis: string;
|
||
}
|
||
const setStatus = (status: 'ready' | 'building') =>
|
||
dataSource.query(
|
||
'UPDATE mail_search_states SET status = $2 WHERE user_email = $1',
|
||
[ME, status],
|
||
);
|
||
|
||
beforeAll(async () => {
|
||
for (const [user, emails] of [
|
||
[ME, DOCUMENTS],
|
||
[
|
||
OTHER,
|
||
// 같은 메일을 받은 다른 직원 — 같은 Message-ID · 제목이어도 그 직원의 대화다
|
||
[
|
||
indexDocument({
|
||
id: 'x-orig',
|
||
subject: '견적 요청',
|
||
messageIds: ['orig@e2e.test'],
|
||
receivedAt: at(1, 1),
|
||
}),
|
||
indexDocument({
|
||
id: 'x-re',
|
||
subject: 'Re: 견적 요청',
|
||
messageIds: ['re1@e2e.test'],
|
||
inReplyTo: ['orig@e2e.test'],
|
||
receivedAt: at(1, 2),
|
||
}),
|
||
],
|
||
],
|
||
] as const) {
|
||
await request(server)
|
||
.get('/api/search-index')
|
||
.set('Cookie', cookieOf(user))
|
||
.expect(200);
|
||
fakeIndexSource.mailboxes.set(user, {
|
||
state: 'c1',
|
||
emails: [...emails],
|
||
});
|
||
expect(
|
||
await app
|
||
.get(MailSearchBuildService)
|
||
.build(user, Date.now() + 120_000),
|
||
).toBe('ready');
|
||
}
|
||
});
|
||
|
||
afterAll(async () => {
|
||
for (const user of [ME, OTHER]) {
|
||
fakeIndexSource.mailboxes.delete(user);
|
||
for (const table of ['mail_search_messages', 'mail_search_states']) {
|
||
await dataSource.query(
|
||
`DELETE FROM "${table}" WHERE user_email = $1`,
|
||
[user],
|
||
);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('대화 키 — 답장 관계 + 말머리(Re · RE · Re[2] · 회신 · 전달 · FW · [꼬리표])를 뗀 제목이 같으면 가장 작은 키 하나, 제목이 다르거나 Message-ID 가 없으면 혼자 · 다른 직원과 섞이지 않는다', async () => {
|
||
const keys = await keysOf(ME);
|
||
for (const id of [
|
||
't-orig',
|
||
't-re',
|
||
't-hoesin',
|
||
't-fwd',
|
||
't-rere',
|
||
't-trash',
|
||
]) {
|
||
expect(keys[id]).toBe('t-fwd');
|
||
}
|
||
expect(keys['t-other']).toBe('t-other');
|
||
expect(keys['t-nomid']).toBe('t-nomid');
|
||
// 원본이 아직 없다 — 두 답장은 따로
|
||
expect(keys['t-late-r1']).toBe('t-late-r1');
|
||
expect(keys['t-late-r2']).toBe('t-late-r2');
|
||
expect(await keysOf(OTHER)).toEqual({
|
||
'x-orig': 'x-orig',
|
||
'x-re': 'x-orig',
|
||
});
|
||
});
|
||
|
||
it('늦게 온 원본은 들어올 때 갈려 있던 답장들을 잇는다 — 같은 메일을 다시 넣어도(겹친 채우기) 합친 키를 풀지 않는다', async () => {
|
||
const repository = app.get(MailSearchIndexRepository);
|
||
fakeIndexSource.mailboxes.get(ME)?.emails.push(LATE_ORIGINAL);
|
||
await repository.write(
|
||
ME,
|
||
{ upserts: [toIndexRow(LATE_ORIGINAL)] },
|
||
new Date(),
|
||
);
|
||
let keys = await keysOf(ME);
|
||
for (const id of ['t-late-orig', 't-late-r1', 't-late-r2']) {
|
||
expect(keys[id]).toBe('t-late-orig');
|
||
}
|
||
|
||
await repository.write(
|
||
ME,
|
||
{ upserts: [toIndexRow(DOCUMENTS[1]), toIndexRow(DOCUMENTS[8])] },
|
||
new Date(),
|
||
);
|
||
keys = await keysOf(ME);
|
||
expect(keys['t-re']).toBe('t-fwd');
|
||
expect(keys['t-late-r1']).toBe('t-late-orig');
|
||
});
|
||
|
||
it('같은 대화 — 오래된 차례 · 보낸편지함 포함 · 휴지통에만 든 메일은 빼되 지금 메일은 넣는다, total · basis index', async () => {
|
||
const page = dataOf<ConversationPage>(
|
||
await conversation('t-rere').expect(200),
|
||
);
|
||
expect(page.items.map((item) => item.id)).toEqual([
|
||
't-orig',
|
||
't-re',
|
||
't-hoesin',
|
||
't-fwd',
|
||
't-rere',
|
||
]);
|
||
expect(page.pageInfo).toEqual({
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: 5,
|
||
});
|
||
expect(page.basis).toBe('index');
|
||
|
||
const fromTrash = dataOf<ConversationPage>(
|
||
await conversation('t-trash').expect(200),
|
||
);
|
||
expect(fromTrash.items.map((item) => item.id)).toContain('t-trash');
|
||
expect(fromTrash.pageInfo.total).toBe(6);
|
||
|
||
// 발송 중인 내부 초안도 그 메일을 열면 넣는다 (엔진 방식과 같다)
|
||
const sending = dataOf<ConversationPage>(
|
||
await conversation('t-sending').expect(200),
|
||
);
|
||
expect(sending.items.map((item) => item.id)).toEqual([
|
||
't-orig',
|
||
't-re',
|
||
't-hoesin',
|
||
't-fwd',
|
||
't-rere',
|
||
't-sending',
|
||
]);
|
||
expect(sending.pageInfo.total).toBe(6);
|
||
|
||
// 한 통뿐인 대화는 그 메일 하나
|
||
const alone = dataOf<ConversationPage>(
|
||
await conversation('t-other').expect(200),
|
||
);
|
||
expect(alone.items.map((item) => item.id)).toEqual(['t-other']);
|
||
});
|
||
|
||
it('같은 대화의 쪽 넘기기 — 커서는 그 메일의 대화에서만, limit 처음 값이자 최대 100', async () => {
|
||
const first = dataOf<ConversationPage>(
|
||
await conversation('t-rere', { limit: 2 }).expect(200),
|
||
);
|
||
expect(first.items.map((item) => item.id)).toEqual(['t-orig', 't-re']);
|
||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 5 });
|
||
const second = dataOf<ConversationPage>(
|
||
await conversation('t-rere', {
|
||
limit: 2,
|
||
cursor: first.pageInfo.nextCursor as string,
|
||
}).expect(200),
|
||
);
|
||
expect(second.items.map((item) => item.id)).toEqual([
|
||
't-hoesin',
|
||
't-fwd',
|
||
]);
|
||
expect(second.pageInfo.total).toBeNull();
|
||
|
||
for (const [id, query] of [
|
||
['t-re', { cursor: first.pageInfo.nextCursor as string }],
|
||
['t-rere', { limit: 101 }],
|
||
['t-rere', { limit: 0 }],
|
||
['t-rere', { q: '견적' }],
|
||
] as const) {
|
||
const res = await conversation(id, query).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
});
|
||
|
||
it('색인 준비 중 · 색인에 없는 메일은 엔진의 스레드(basis engine) — 엔진에도 없으면 404 RES_001', async () => {
|
||
const before = engineConversations.length;
|
||
const missing = await conversation(MISSING_MESSAGE).expect(404);
|
||
expect(body(missing).error?.code).toBe('RES_001');
|
||
await setStatus('building');
|
||
try {
|
||
const engine = dataOf<ConversationPage>(
|
||
await conversation('t-re').expect(200),
|
||
);
|
||
expect(engine.basis).toBe('engine');
|
||
} finally {
|
||
await setStatus('ready');
|
||
}
|
||
expect(
|
||
engineConversations.slice(before).map((call) => call.emailId),
|
||
).toEqual([MISSING_MESSAGE, 't-re']);
|
||
});
|
||
|
||
it('대화로 묶은 목록 — 그 메일함 안 마지막 받은 시각 최신부터, 통 수는 대화 전체(휴지통에만 든 것 빼고) · 그 메일함 안 id · 읽음 · 참여자', async () => {
|
||
const page = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'inbox' }).expect(200),
|
||
);
|
||
expect(page.basis).toBe('index');
|
||
expect(page.items.map((row) => row.latest.id)).toEqual([
|
||
't-late-r2',
|
||
't-nomid',
|
||
't-other',
|
||
't-rere',
|
||
]);
|
||
expect(page.pageInfo).toEqual({
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: 4,
|
||
});
|
||
const quote = page.items[3];
|
||
expect(quote).toMatchObject({
|
||
id: 't-fwd',
|
||
messageCount: 5,
|
||
messageIds: ['t-rere', 't-fwd', 't-re', 't-orig'],
|
||
pendingSendIds: [],
|
||
isRead: false,
|
||
isFlagged: false,
|
||
hasAttachment: false,
|
||
});
|
||
expect(quote.participants.map((p) => p.email)).toEqual([
|
||
'doyun.lee@e2e.arasystem.test',
|
||
ME,
|
||
]);
|
||
expect(page.items[0]).toMatchObject({
|
||
id: 't-late-orig',
|
||
messageCount: 3,
|
||
messageIds: ['t-late-r2', 't-late-r1', 't-late-orig'],
|
||
});
|
||
|
||
// 보낸편지함에서는 그 메일함의 메일만 — 통 수는 대화 전체. 붙잡힌 메일(보내기 취소를 기다리는)은 pendingSendIds 로 (리뷰 L6)
|
||
const before = heldSummaryRequests.length;
|
||
fakeHeldIds.add('t-hoesin');
|
||
try {
|
||
const sent = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'sent' }).expect(200),
|
||
);
|
||
expect(sent.items).toHaveLength(1);
|
||
expect(sent.items[0]).toMatchObject({
|
||
latest: { id: 't-hoesin' },
|
||
messageIds: ['t-hoesin'],
|
||
pendingSendIds: ['t-hoesin'],
|
||
messageCount: 5,
|
||
});
|
||
} finally {
|
||
fakeHeldIds.clear();
|
||
}
|
||
// 기다리는 제출은 쪽마다 한 번 — 마지막 메일의 요약과 같은 요청
|
||
expect(heldSummaryRequests.slice(before)).toEqual([
|
||
{ user: ME, ids: ['t-hoesin'] },
|
||
]);
|
||
});
|
||
|
||
it('참여자는 첫 보낸 사람 칸에서 — 이름 안의 `<주소>` 는 참여자 주소가 되지 않는다 (리뷰 L3)', async () => {
|
||
const keys = await keysOf(ME);
|
||
expect(keys['t-notice-re']).toBe('t-notice');
|
||
const senders = await dataSource.query<
|
||
{
|
||
email_id: string;
|
||
from_email: string | null;
|
||
from_name: string | null;
|
||
}[]
|
||
>(
|
||
'SELECT email_id, from_email, from_name FROM mail_search_messages WHERE user_email = $1 AND email_id = ANY($2::text[]) ORDER BY email_id',
|
||
[ME, ['t-notice', 't-notice-re']],
|
||
);
|
||
expect(senders).toEqual([
|
||
{ email_id: 't-notice', from_email: null, from_name: null },
|
||
{
|
||
email_id: 't-notice-re',
|
||
from_email: 'doyun.lee@e2e.arasystem.test',
|
||
from_name: '이도윤',
|
||
},
|
||
]);
|
||
const notice = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'notice' }).expect(200),
|
||
);
|
||
expect(notice.items).toHaveLength(1);
|
||
expect(notice.items[0]).toMatchObject({
|
||
messageCount: 2,
|
||
messageIds: ['t-notice-re', 't-notice'],
|
||
participants: [
|
||
{ name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' },
|
||
],
|
||
});
|
||
});
|
||
|
||
it('꾸민 색인 커서(모양이 틀림 · `m` 만 맞음)는 두 경로 모두 400 VAL_001 — 색인에 없는 메일이어도 404 보다 먼저 (리뷰 L1 · L8)', async () => {
|
||
const forged = (payload: Record<string, unknown>) =>
|
||
Buffer.from(JSON.stringify(payload)).toString('base64url');
|
||
for (const [id, cursor] of [
|
||
['t-rere', forged({ m: 'ti' })],
|
||
['t-rere', forged({ v: 1, m: 'ti', t: 'x', k: '../a', s: 'zz' })],
|
||
[MISSING_MESSAGE, forged({ m: 'ti' })],
|
||
[MISSING_MESSAGE, forged({ v: 1, m: 'ti', t: 1, k: 'a', s: 'zz' })],
|
||
] as const) {
|
||
const res = await conversation(id, { cursor }).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
for (const cursor of [
|
||
forged({ m: 'li' }),
|
||
forged({ v: 1, m: 'li', t: -1, k: 't-fwd', s: 'zz' }),
|
||
]) {
|
||
const res = await conversations({ mailboxId: 'inbox', cursor }).expect(
|
||
400,
|
||
);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
});
|
||
|
||
it('묶은 목록의 쪽 넘기기 · 범위 — 커서는 그 메일함에서만, 없는 메일함은 404 · mailboxId 없음은 400', async () => {
|
||
const first = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'inbox', limit: 2 }).expect(200),
|
||
);
|
||
expect(first.items.map((row) => row.latest.id)).toEqual([
|
||
't-late-r2',
|
||
't-nomid',
|
||
]);
|
||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 4 });
|
||
const second = dataOf<ConversationListPage>(
|
||
await conversations({
|
||
mailboxId: 'inbox',
|
||
limit: 2,
|
||
cursor: first.pageInfo.nextCursor as string,
|
||
}).expect(200),
|
||
);
|
||
expect(second.items.map((row) => row.latest.id)).toEqual([
|
||
't-other',
|
||
't-rere',
|
||
]);
|
||
expect(second.pageInfo).toEqual({
|
||
hasNext: false,
|
||
nextCursor: null,
|
||
total: null,
|
||
});
|
||
|
||
const otherMailbox = await conversations({
|
||
mailboxId: 'sent',
|
||
cursor: first.pageInfo.nextCursor as string,
|
||
}).expect(400);
|
||
expect(body(otherMailbox).error?.code).toBe('VAL_001');
|
||
const missing = await conversations({
|
||
mailboxId: MISSING_MAILBOX,
|
||
}).expect(404);
|
||
expect(body(missing).error?.code).toBe('RES_001');
|
||
const invalid: Record<string, string>[] = [
|
||
{},
|
||
{ mailboxId: '../a' },
|
||
{ mailboxId: 'inbox', q: '견적' },
|
||
];
|
||
for (const query of invalid) {
|
||
const res = await conversations(query).expect(400);
|
||
expect(body(res).error?.code).toBe('VAL_001');
|
||
}
|
||
});
|
||
|
||
it('색인 준비 중이면 엔진의 스레드(basis engine — collapseThreads) · 다른 직원의 줄은 섞이지 않는다', async () => {
|
||
const before = engineConversationLists.length;
|
||
await setStatus('building');
|
||
try {
|
||
const engine = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'inbox' }).expect(200),
|
||
);
|
||
expect(engine.basis).toBe('engine');
|
||
} finally {
|
||
await setStatus('ready');
|
||
}
|
||
expect(engineConversationLists.slice(before)).toEqual([
|
||
{
|
||
user: ME,
|
||
query: { mailboxId: 'inbox', limit: 20, cursor: undefined },
|
||
},
|
||
]);
|
||
const mine = dataOf<ConversationListPage>(
|
||
await conversations({ mailboxId: 'inbox' }).expect(200),
|
||
);
|
||
expect(
|
||
mine.items
|
||
.flatMap((row) => [row.id, ...row.messageIds])
|
||
.filter((id) => id.startsWith('x-')),
|
||
).toEqual([]);
|
||
});
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|
||
});
|