generated from ttipo/project-base
feat(backend): 비밀번호 변경 · 메일함 관리 · 부재중 자동 응답 · 스팸 표시 · 원문 API
- PUT /api/auth/password: 직원 본인 자격으로 엔진의 x:AccountPassword/set(게이트웨이 · 관리자 권한 없이), 로그인과 같은 실패 카운터 · 진행 중 잠금.
바뀌면 리프레시 쿠키의 세션만 남기고 끊는다 — 결과를 모르는 실패에도 끊는다. 엔진이 바꾸기만 거절하면 세지 않는다
- 메일함 만들기 · 이름 바꾸기 · 지우기(안의 메일은 휴지통으로, onDestroyRemoveEmails false), 모두 읽음 · 휴지통 / 스팸메일함 비우기(조건부 지우기)
— 500통씩 · 한 요청 25초 상한(엔진 호출마다 남은 시간), 같은 직원 · 같은 메일함은 하나씩(REQ_001), 진행 없는 차례가 이어지면 멈춘다
- GET · PUT /api/mail-vacation(켤 때만 제목 · 내용 필수, 제어 문자 검사, 초 단위), PATCH /api/messages(/{id}) 의 isJunk,
GET /api/messages/{id}/source(message/rfc822)
- 에러 코드 AUTH_005 · MAIL_009 추가와 클라이언트 사전 다시 생성, 공용 길이 검사 데코레이터(JS 문자열 길이)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5,6 +5,7 @@ import { createHash } from 'crypto';
|
||||
import { existsSync, unlinkSync } from 'fs';
|
||||
import type { Server } from 'http';
|
||||
import { basename, join } from 'path';
|
||||
import { Readable } from 'stream';
|
||||
import request from 'supertest';
|
||||
import type { Response } from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
@@ -28,11 +29,19 @@ import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
Mailbox,
|
||||
MailboxBulkResult,
|
||||
MailboxCreate,
|
||||
MailboxDeletion,
|
||||
MailVacation,
|
||||
MessageDeletionResult,
|
||||
MessageListQuery,
|
||||
MessageSource,
|
||||
MessagesPatchResult,
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
PasswordChangeOutcome,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
@@ -43,6 +52,7 @@ import {
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
@@ -116,6 +126,35 @@ const MISSING_MAILBOX = 'nomailbox';
|
||||
/** 대역 메일 엔진이 "그사이 지워졌다"고 답하는 메일 id (failedIds · skippedIds 경로) */
|
||||
const GONE_MESSAGE = 'gonemessage';
|
||||
|
||||
/** 대역 메일 엔진이 "엔진 규칙에 어긋난다"고 답하는 새 비밀번호 (400 VAL_001 경로) */
|
||||
const ENGINE_REJECTED_PASSWORD = 'engine-rejects-this-one';
|
||||
|
||||
/** 대역 메일 엔진이 "지금 비밀번호로 들어갔지만 바꾸기는 거절했다"고 답하는 새 비밀번호 (400 AUTH_005 — 실패로 세지 않는다) */
|
||||
const ENGINE_REFUSED_PASSWORD = 'engine-refuses-this-change';
|
||||
|
||||
/** 대역 메일 엔진의 비밀번호 바꾸기가 결과를 알 수 없게 끝나는 새 비밀번호 (503 MAIL_001 — 다른 세션은 끊어 둔다) */
|
||||
const ENGINE_UNKNOWN_PASSWORD = 'engine-outcome-unknown';
|
||||
|
||||
/** 대역 메일 엔진이 "같은 상위 안에 같은 이름이 있다"고 답하는 메일함 이름 (409 MAIL_009 경로) */
|
||||
const TAKEN_MAILBOX_NAME = '이미 있는 이름';
|
||||
|
||||
/** 대역 메일 엔진의 비우기가 테스트가 문을 열 때까지 끝나지 않는 메일함 — 같은 메일함의 처리 중 잠금 확인용 */
|
||||
const SLOW_MAILBOX = 'slowbox';
|
||||
|
||||
/** 한 번도 설정하지 않은 계정의 부재중 자동 응답 (계약 2절) */
|
||||
const NEVER_CONFIGURED_VACATION: MailVacation = {
|
||||
isEnabled: false,
|
||||
fromDate: null,
|
||||
toDate: null,
|
||||
subject: '',
|
||||
textBody: '',
|
||||
};
|
||||
|
||||
/** 대역 메일 엔진이 돌려주는 메일 원문과 그 제목 */
|
||||
const RAW_MESSAGE_SUBJECT = 'Re: 견적서/10월';
|
||||
const RAW_MESSAGE =
|
||||
'From: doyun.lee@e2e.arasystem.test\r\nSubject: Re: 견적서/10월\r\n\r\n견적서를 보냅니다.\r\n';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
@@ -194,6 +233,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
|
||||
*/
|
||||
let slowEngineGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 SLOW_MAILBOX 비우기가 기다리는 문 — 테스트가 만들고 연다. null 이면 바로 답한다 */
|
||||
let slowMailboxGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
|
||||
const drafts = new Map<
|
||||
string,
|
||||
@@ -217,6 +258,17 @@ describe('응답 계약 (e2e)', () => {
|
||||
}[] = [];
|
||||
const trashDeletions: { user: string; ids: readonly string[] }[] = [];
|
||||
|
||||
/** 4단계 — 대역 엔진이 받은 비밀번호 바꾸기 · 메일함 관리 · 부재중 자동 응답 · 한 통 바꾸기 */
|
||||
const passwordChanges: { email: string; newPassword: string }[] = [];
|
||||
const mailboxCalls: { user: string; action: string; args: unknown[] }[] = [];
|
||||
const vacations = new Map<string, MailVacation>();
|
||||
const vacationSaves: { user: string; vacation: MailVacation }[] = [];
|
||||
const singleUpdates: {
|
||||
user: string;
|
||||
id: string;
|
||||
update: MessageUpdate;
|
||||
}[] = [];
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
jwt.sign(payload, {
|
||||
@@ -370,6 +422,140 @@ describe('응답 계약 (e2e)', () => {
|
||||
skippedIds: ids.filter((id) => id === GONE_MESSAGE),
|
||||
});
|
||||
},
|
||||
// 4단계 — 엔진 쪽 판단(본인 자격의 비밀번호 바꾸기 · 조건부로 옮기기 · 비우기)은 jmap-*.spec.ts 가 본다. 여기서는 받은 값만 흉내 낸다
|
||||
changePassword: (
|
||||
email: string,
|
||||
currentPassword: string,
|
||||
newPassword: string,
|
||||
): Promise<PasswordChangeOutcome> => {
|
||||
passwordChanges.push({ email, newPassword });
|
||||
if (currentPassword !== CORRECT_PASSWORD) {
|
||||
return Promise.resolve('current-password-rejected');
|
||||
}
|
||||
const outcomes = new Map<string, PasswordChangeOutcome>([
|
||||
[ENGINE_REJECTED_PASSWORD, 'new-password-rejected'],
|
||||
[ENGINE_REFUSED_PASSWORD, 'current-password-refused'],
|
||||
[ENGINE_UNKNOWN_PASSWORD, 'outcome-unknown'],
|
||||
]);
|
||||
return Promise.resolve(outcomes.get(newPassword) ?? 'changed');
|
||||
},
|
||||
createMailbox: (
|
||||
user: string,
|
||||
input: MailboxCreate,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'create', args: [input] });
|
||||
if (input.name === TAKEN_MAILBOX_NAME) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_009', {
|
||||
context: { reason: 'e2e-mailbox-name-taken' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (input.parentId === MISSING_MAILBOX) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-parent-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'newbox',
|
||||
name: input.name,
|
||||
role: null,
|
||||
parentId: input.parentId,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
});
|
||||
},
|
||||
renameMailbox: (
|
||||
user: string,
|
||||
id: string,
|
||||
name: string,
|
||||
): Promise<Mailbox> => {
|
||||
mailboxCalls.push({ user, action: 'rename', args: [id, name] });
|
||||
return Promise.resolve({
|
||||
id,
|
||||
name,
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 3,
|
||||
unreadCount: 1,
|
||||
});
|
||||
},
|
||||
deleteMailbox: (user: string, id: string): Promise<MailboxDeletion> => {
|
||||
mailboxCalls.push({ user, action: 'delete', args: [id] });
|
||||
return Promise.resolve({
|
||||
deleted: true,
|
||||
processed: 4,
|
||||
movedToTrash: 3,
|
||||
remaining: 0,
|
||||
});
|
||||
},
|
||||
markMailboxRead: (
|
||||
user: string,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> => {
|
||||
mailboxCalls.push({ user, action: 'mark-read', args: [id] });
|
||||
return Promise.resolve({ processed: 5, remaining: 0 });
|
||||
},
|
||||
emptyMailbox: async (
|
||||
user: string,
|
||||
id: string,
|
||||
): Promise<MailboxBulkResult> => {
|
||||
mailboxCalls.push({ user, action: 'empty', args: [id] });
|
||||
if (id === SLOW_MAILBOX && slowMailboxGate) await slowMailboxGate;
|
||||
return { processed: 2, remaining: 0 };
|
||||
},
|
||||
getVacation: (user: string): Promise<MailVacation> =>
|
||||
Promise.resolve(vacations.get(user) ?? NEVER_CONFIGURED_VACATION),
|
||||
setVacation: (
|
||||
user: string,
|
||||
vacation: MailVacation,
|
||||
): Promise<MailVacation> => {
|
||||
vacationSaves.push({ user, vacation });
|
||||
vacations.set(user, vacation);
|
||||
return Promise.resolve(vacation);
|
||||
},
|
||||
openMessageSource: (
|
||||
_user: string,
|
||||
id: string,
|
||||
): Promise<MessageSource> => {
|
||||
if (id === GONE_MESSAGE) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-message-not-found' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
stream: Readable.from([Buffer.from(RAW_MESSAGE, 'utf8')]),
|
||||
subject: RAW_MESSAGE_SUBJECT,
|
||||
size: Buffer.byteLength(RAW_MESSAGE, 'utf8'),
|
||||
});
|
||||
},
|
||||
updateMessage: (
|
||||
user: string,
|
||||
id: string,
|
||||
update: MessageUpdate,
|
||||
): Promise<MessageSummary> => {
|
||||
singleUpdates.push({ user, id, update });
|
||||
return Promise.resolve({
|
||||
id,
|
||||
threadId: id,
|
||||
mailboxIds: [update.mailboxId ?? 'a'],
|
||||
from: null,
|
||||
to: [],
|
||||
subject: '',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-28T01:00:00.000Z',
|
||||
isRead: false,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 1,
|
||||
});
|
||||
},
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -752,6 +938,15 @@ describe('응답 계약 (e2e)', () => {
|
||||
['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/messages/eaaaaab/source'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -1669,6 +1864,585 @@ describe('응답 계약 (e2e)', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('4단계 — 비밀번호 · 메일함 관리 · 부재중 자동 응답 · 스팸 · 원문', () => {
|
||||
describe('비밀번호 바꾸기 (PUT /api/auth/password)', () => {
|
||||
const NEW_PASSWORD = 'new-password-for-e2e';
|
||||
|
||||
/** 로그인한 두 쿠키로 보낸다 — 리프레시 쿠키(Path=/api/auth)가 이 세션을 알려 준다 */
|
||||
const changeWith = (
|
||||
session: { access: string; refresh: string },
|
||||
payload: object,
|
||||
ip: string,
|
||||
) =>
|
||||
request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.set(
|
||||
'Cookie',
|
||||
`access_token=${session.access}; refresh_token=${session.refresh}`,
|
||||
)
|
||||
.send(payload);
|
||||
|
||||
/** 액세스 쿠키만으로 보낸다 (형식 검사 · 한도 확인용) */
|
||||
const changeAs = (email: string, payload: object, ip: string) =>
|
||||
request(server)
|
||||
.put('/api/auth/password')
|
||||
.set('X-Forwarded-For', ip)
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send(payload);
|
||||
|
||||
/** 리프레시 토큰이 가리키는 세션의 끝난 사유 */
|
||||
const revokedReasonOf = async (refreshToken: string) => {
|
||||
const { sid } = jwt.decode<{ sid: string }>(refreshToken);
|
||||
const rows: { revoked_reason: string | null }[] =
|
||||
await dataSource.query(
|
||||
'SELECT revoked_reason FROM auth_sessions WHERE id = $1',
|
||||
[sid],
|
||||
);
|
||||
return rows[0]?.revoked_reason;
|
||||
};
|
||||
|
||||
it('바뀌면 200 data null — 이 세션만 남기고 그 직원의 다른 세션을 끊는다 (쿠키는 그대로)', async () => {
|
||||
const email = 'pw.change@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
||||
'198.51.100.200',
|
||||
).expect(200);
|
||||
|
||||
expect(body(res)).toEqual({ success: true, data: null });
|
||||
expect(res.headers['set-cookie']).toBeUndefined();
|
||||
expect(passwordChanges.at(-1)).toEqual({
|
||||
email,
|
||||
newPassword: NEW_PASSWORD,
|
||||
});
|
||||
expect(await revokedReasonOf(other.refresh)).toBe('password-changed');
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
const cut = await refreshWith(other.refresh).expect(401);
|
||||
expect(body(cut).error?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('지금 비밀번호가 틀리면 401 이 아니라 400 AUTH_005 — 세션은 그대로 · 엔진 규칙에 어긋난 새 비밀번호는 400 VAL_001', async () => {
|
||||
const mine = await login('pw.wrong@e2e.arasystem.test');
|
||||
|
||||
const wrong = await changeWith(
|
||||
mine,
|
||||
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
||||
'198.51.100.201',
|
||||
).expect(400);
|
||||
expect(body(wrong).error?.code).toBe('AUTH_005');
|
||||
|
||||
const rejected = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_REJECTED_PASSWORD,
|
||||
},
|
||||
'198.51.100.201',
|
||||
).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
});
|
||||
|
||||
it('지금 비밀번호로 들어갔는데 엔진이 바꾸기를 거절하면 400 AUTH_005 — 실패로 세지 않아 10번을 넘겨도 429 가 아니고, 세션은 그대로', async () => {
|
||||
const email = 'pw.refused@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
for (let i = 0; i < 12; i += 1) {
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_REFUSED_PASSWORD,
|
||||
},
|
||||
'198.51.100.240',
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('AUTH_005');
|
||||
}
|
||||
|
||||
expect(await revokedReasonOf(other.refresh)).toBeNull();
|
||||
await refreshWith(other.refresh).expect(200);
|
||||
});
|
||||
|
||||
it('결과를 알 수 없게 끝나면 503 MAIL_001 — 엔진이 바꿨을 수 있어 이 세션만 남기고 다른 세션은 끊어 둔다', async () => {
|
||||
const email = 'pw.unknown@e2e.arasystem.test';
|
||||
const mine = await login(email);
|
||||
const other = await login(email);
|
||||
|
||||
const res = await changeWith(
|
||||
mine,
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: ENGINE_UNKNOWN_PASSWORD,
|
||||
},
|
||||
'198.51.100.241',
|
||||
).expect(503);
|
||||
|
||||
expect(body(res).error?.code).toBe('MAIL_001');
|
||||
expect(await revokedReasonOf(other.refresh)).toBe(
|
||||
'password-change-uncertain',
|
||||
);
|
||||
expect(await revokedReasonOf(mine.refresh)).toBeNull();
|
||||
await refreshWith(mine.refresh).expect(200);
|
||||
const cut = await refreshWith(other.refresh).expect(401);
|
||||
expect(body(cut).error?.code).toBe('AUTH_001');
|
||||
});
|
||||
|
||||
it('형식 오류는 400 VAL_001 — 7자 · 129자 · 지금과 같음 · 빠진 필드 · 모르는 필드, 엔진에 묻지 않는다', async () => {
|
||||
const before = passwordChanges.length;
|
||||
|
||||
for (const payload of [
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(7) },
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: 'x'.repeat(129) },
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: CORRECT_PASSWORD },
|
||||
{ newPassword: NEW_PASSWORD },
|
||||
{ currentPassword: '', newPassword: NEW_PASSWORD },
|
||||
{
|
||||
currentPassword: CORRECT_PASSWORD,
|
||||
newPassword: NEW_PASSWORD,
|
||||
confirmPassword: NEW_PASSWORD,
|
||||
},
|
||||
]) {
|
||||
const res = await changeAs(
|
||||
'pw.format@e2e.arasystem.test',
|
||||
payload,
|
||||
'198.51.100.202',
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(passwordChanges).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('틀린 지금 비밀번호는 로그인 실패와 같은 한도 — 같은 주소로 10번이 넘으면 맞는 비밀번호도 429 REQ_001, 그 주소의 로그인도 막힌다', async () => {
|
||||
const email = 'pw.locked@e2e.arasystem.test';
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
const res = await changeAs(
|
||||
email,
|
||||
{ currentPassword: 'wrong-password', newPassword: NEW_PASSWORD },
|
||||
`198.51.100.${210 + i}`,
|
||||
).expect(400);
|
||||
expect(body(res).error?.code).toBe('AUTH_005');
|
||||
}
|
||||
|
||||
const locked = await changeAs(
|
||||
email,
|
||||
{ currentPassword: CORRECT_PASSWORD, newPassword: NEW_PASSWORD },
|
||||
'198.51.100.230',
|
||||
).expect(429);
|
||||
expect(body(locked).error?.code).toBe('REQ_001');
|
||||
|
||||
const login = await request(server)
|
||||
.post('/api/auth/login')
|
||||
.set('X-Forwarded-For', '198.51.100.231')
|
||||
.send({ email, password: CORRECT_PASSWORD })
|
||||
.expect(429);
|
||||
expect(body(login).error?.code).toBe('REQ_001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('메일함 관리 (/api/mailboxes)', () => {
|
||||
const ME = 'box.me@e2e.arasystem.test';
|
||||
const send = (
|
||||
method: 'post' | 'patch' | 'delete',
|
||||
path: string,
|
||||
payload?: object,
|
||||
) => {
|
||||
const req = request(server)[method](path).set('Cookie', cookieOf(ME));
|
||||
return payload === undefined ? req : req.send(payload);
|
||||
};
|
||||
|
||||
it('만들기는 201 Mailbox — 이름은 앞뒤 공백을 빼고, 상위가 없으면 맨 위(null)로 엔진에 넘긴다', async () => {
|
||||
const res = await send('post', '/api/mailboxes', {
|
||||
name: ' 거래처 ',
|
||||
}).expect(201);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'newbox',
|
||||
name: '거래처',
|
||||
role: null,
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
totalCount: 0,
|
||||
unreadCount: 0,
|
||||
},
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'create',
|
||||
args: [{ name: '거래처', parentId: null }],
|
||||
});
|
||||
|
||||
await send('post', '/api/mailboxes', {
|
||||
name: '하위',
|
||||
parentId: 'a',
|
||||
}).expect(201);
|
||||
expect(mailboxCalls.at(-1)?.args).toEqual([
|
||||
{ name: '하위', parentId: 'a' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('이름 형식 · 상위 id 형식 · 모르는 필드는 400 VAL_001 — 엔진에 묻지 않는다', async () => {
|
||||
const before = mailboxCalls.length;
|
||||
|
||||
for (const payload of [
|
||||
{},
|
||||
{ name: '' },
|
||||
{ name: ' ' },
|
||||
{ name: 'x'.repeat(65) },
|
||||
{ name: '거래처/2026' },
|
||||
{ name: '거래\t처' },
|
||||
{ name: '거래처', parentId: '../a' },
|
||||
{ name: '거래처', role: 'inbox' },
|
||||
]) {
|
||||
const res = await send('post', '/api/mailboxes', payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('같은 상위 안에 같은 이름은 409 MAIL_009, 없는 상위는 404 RES_001', async () => {
|
||||
const taken = await send('post', '/api/mailboxes', {
|
||||
name: TAKEN_MAILBOX_NAME,
|
||||
}).expect(409);
|
||||
expect(body(taken).error?.code).toBe('MAIL_009');
|
||||
|
||||
const orphan = await send('post', '/api/mailboxes', {
|
||||
name: '거래처',
|
||||
parentId: MISSING_MAILBOX,
|
||||
}).expect(404);
|
||||
expect(body(orphan).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('이름 바꾸기는 200 Mailbox — 형식이 틀린 id 는 404 RES_001, 상위는 바꾸지 않는다(모르는 필드)', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/u1', {
|
||||
name: ' 새 이름 ',
|
||||
}).expect(200);
|
||||
expect(dataOf<{ name: string }>(res).name).toBe('새 이름');
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'rename',
|
||||
args: ['u1', '새 이름'],
|
||||
});
|
||||
|
||||
const malformed = await send('patch', '/api/mailboxes/a%20b', {
|
||||
name: '새 이름',
|
||||
}).expect(404);
|
||||
expect(body(malformed).error?.code).toBe('RES_001');
|
||||
|
||||
const move = await send('patch', '/api/mailboxes/u1', {
|
||||
name: '새 이름',
|
||||
parentId: 'a',
|
||||
}).expect(400);
|
||||
expect(body(move).error?.code).toBe('VAL_001');
|
||||
});
|
||||
|
||||
it('지우기는 204 가 아니라 200 { deleted, processed, movedToTrash, remaining }', async () => {
|
||||
const res = await send('delete', '/api/mailboxes/u1').expect(200);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { deleted: true, processed: 4, movedToTrash: 3, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'delete',
|
||||
args: ['u1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('전체 읽음은 { isRead: true } 만 받는다 — 200 { processed, remaining }', async () => {
|
||||
const res = await send('patch', '/api/mailboxes/a/messages', {
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { processed: 5, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'mark-read',
|
||||
args: ['a'],
|
||||
});
|
||||
|
||||
const before = mailboxCalls.length;
|
||||
for (const payload of [
|
||||
{ isRead: false },
|
||||
{},
|
||||
{ isRead: true, isFlagged: true },
|
||||
]) {
|
||||
const rejected = await send(
|
||||
'patch',
|
||||
'/api/mailboxes/a/messages',
|
||||
payload,
|
||||
).expect(400);
|
||||
expect(body(rejected).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('비우기는 200 { processed, remaining }', async () => {
|
||||
const res = await send('delete', '/api/mailboxes/b/messages').expect(
|
||||
200,
|
||||
);
|
||||
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: { processed: 2, remaining: 0 },
|
||||
});
|
||||
expect(mailboxCalls.at(-1)).toEqual({
|
||||
user: ME,
|
||||
action: 'empty',
|
||||
args: ['b'],
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 같은 메일함의 처리 중 잠금 — 대역 엔진의 SLOW_MAILBOX 비우기를 문으로 붙잡아 둔 채 다음 요청을 보낸다.
|
||||
* 붙잡힌 요청이 있는 동안 supertest 가 서버를 닫지 않게 서버를 미리 열어 둔다 (로그인 동시 시도 테스트와 같은 이유)
|
||||
*/
|
||||
it(
|
||||
'같은 직원 · 같은 메일함의 오래 걸리는 처리가 처리 중이면 종류가 달라도 엔진에 묻지 않고 429 REQ_001 — 다른 메일함은 그대로, 끝나면 다시 받는다',
|
||||
async () => {
|
||||
const httpServer = app.getHttpServer();
|
||||
const startedServer = !httpServer.listening;
|
||||
if (startedServer) await listenOnAnyPort(httpServer);
|
||||
|
||||
const gate = createGate();
|
||||
slowMailboxGate = gate.opened;
|
||||
const before = mailboxCalls.length;
|
||||
const first = send(
|
||||
'delete',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
).then((res) => res);
|
||||
|
||||
try {
|
||||
await waitUntil(
|
||||
() => mailboxCalls.length > before,
|
||||
GATE_DEADLINE_MS,
|
||||
);
|
||||
expect(mailboxCalls.slice(before)).toEqual([
|
||||
{ user: ME, action: 'empty', args: [SLOW_MAILBOX] },
|
||||
]);
|
||||
|
||||
for (const [method, path, payload] of [
|
||||
['delete', `/api/mailboxes/${SLOW_MAILBOX}/messages`, undefined],
|
||||
[
|
||||
'patch',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
{ isRead: true },
|
||||
],
|
||||
['delete', `/api/mailboxes/${SLOW_MAILBOX}`, undefined],
|
||||
] as const) {
|
||||
const busy = await send(method, path, payload).expect(429);
|
||||
expect(body(busy).error?.code).toBe('REQ_001');
|
||||
}
|
||||
expect(mailboxCalls).toHaveLength(before + 1);
|
||||
|
||||
// 같은 때 같은 IP 에서 다른 메일함은 된다 — 위의 429 는 요청 수 한도가 아니다
|
||||
await send('patch', '/api/mailboxes/a/messages', {
|
||||
isRead: true,
|
||||
}).expect(200);
|
||||
} finally {
|
||||
// 실패해도 문을 열고 붙잡힌 요청이 끝나기를 기다린다 — 다음 테스트 · 앱 종료가 멈추지 않게
|
||||
gate.open();
|
||||
slowMailboxGate = null;
|
||||
await Promise.allSettled([first]);
|
||||
if (startedServer) await closeServer(httpServer);
|
||||
}
|
||||
|
||||
expect((await first).status).toBe(200);
|
||||
await send(
|
||||
'delete',
|
||||
`/api/mailboxes/${SLOW_MAILBOX}/messages`,
|
||||
).expect(200);
|
||||
},
|
||||
GATED_TEST_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe('부재중 자동 응답 (/api/mail-vacation)', () => {
|
||||
const ME = 'vacation.me@e2e.arasystem.test';
|
||||
const vacation = {
|
||||
isEnabled: true,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
subject: '부재중입니다',
|
||||
textBody: '11월 7일까지 휴가입니다.',
|
||||
};
|
||||
const put = (payload: object) =>
|
||||
request(server)
|
||||
.put('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send(payload);
|
||||
|
||||
it('한 번도 설정하지 않았으면 끈 상태 · 빈 제목과 내용 — 받은 그대로 다시 저장해도 200', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
expect(body(res)).toEqual({
|
||||
success: true,
|
||||
data: NEVER_CONFIGURED_VACATION,
|
||||
});
|
||||
|
||||
const restored = await put(dataOf<object>(res)).expect(200);
|
||||
expect(dataOf<unknown>(restored)).toEqual(NEVER_CONFIGURED_VACATION);
|
||||
});
|
||||
|
||||
it('저장하면(전체 교체) 엔진이 저장한 값을 돌려주고, 다음 조회도 그 값이다', async () => {
|
||||
const res = await put(vacation).expect(200);
|
||||
expect(dataOf<unknown>(res)).toEqual(vacation);
|
||||
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation });
|
||||
|
||||
const read = await request(server)
|
||||
.get('/api/mail-vacation')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(read)).toEqual(vacation);
|
||||
});
|
||||
|
||||
it('켤 때 빈(공백뿐인) 제목 · 내용, 제어 문자, 시작이 끝보다 늦음 · 같음(같은 초 포함), 날짜 형식, 빠진 키, 길이 초과, 서식 본문은 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(201) },
|
||||
{ ...vacation, textBody: 'x'.repeat(4_001) },
|
||||
{ ...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('꺼 둔 채로는 공백뿐인 제목도 저장한다 — 쓴 그대로(내용의 탭 · 줄바꿈 포함), 날짜는 소수 초를 버려 저장한다', async () => {
|
||||
const off = {
|
||||
...vacation,
|
||||
isEnabled: false,
|
||||
fromDate: '2026-11-02T15:00:00.750Z',
|
||||
toDate: '2026-11-07T15:00:00.250Z',
|
||||
subject: ' ',
|
||||
textBody: '안녕하세요.\r\n\t11월 7일까지 휴가입니다.\n',
|
||||
};
|
||||
|
||||
const res = await put(off).expect(200);
|
||||
|
||||
const saved = {
|
||||
...off,
|
||||
fromDate: '2026-11-02T15:00:00.000Z',
|
||||
toDate: '2026-11-07T15:00:00.000Z',
|
||||
};
|
||||
expect(dataOf<unknown>(res)).toEqual(saved);
|
||||
expect(vacationSaves.at(-1)).toEqual({ user: ME, vacation: saved });
|
||||
});
|
||||
});
|
||||
|
||||
describe('스팸 신고 · 스팸 아님 (isJunk)', () => {
|
||||
const ME = 'junk.me@e2e.arasystem.test';
|
||||
|
||||
it('한 통 PATCH 는 이동과 함께 isJunk 를 엔진에 넘긴다 — true · false 가 아니면 400 VAL_001', async () => {
|
||||
await request(server)
|
||||
.patch('/api/messages/eaaaaab')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ mailboxId: 'c', isJunk: true })
|
||||
.expect(200);
|
||||
expect(singleUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
id: 'eaaaaab',
|
||||
update: { mailboxId: 'c', isJunk: true },
|
||||
});
|
||||
|
||||
const before = singleUpdates.length;
|
||||
for (const isJunk of ['yes', null, 1]) {
|
||||
const res = await request(server)
|
||||
.patch('/api/messages/eaaaaab')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ isJunk })
|
||||
.expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
expect(singleUpdates).toHaveLength(before);
|
||||
});
|
||||
|
||||
it('여러 통 PATCH 도 isJunk 만으로 바꿀 수 있다 (스팸 아님)', async () => {
|
||||
await request(server)
|
||||
.patch('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ ids: ['eaaaaab'], mailboxId: 'a', isJunk: false })
|
||||
.expect(200);
|
||||
|
||||
expect(bulkUpdates.at(-1)).toEqual({
|
||||
user: ME,
|
||||
ids: ['eaaaaab'],
|
||||
update: { mailboxId: 'a', isJunk: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('원문 내려받기 (GET /api/messages/{id}/source)', () => {
|
||||
const ME = 'source.me@e2e.arasystem.test';
|
||||
|
||||
it('표준 래퍼 없이 message/rfc822 원문 — attachment(제목으로 만든 .eml) · private, no-store · nosniff', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/messages/eaaaaab/source')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.responseType('blob')
|
||||
.expect(200);
|
||||
|
||||
expect(res.headers['content-type']).toBe('message/rfc822');
|
||||
expect(res.headers['content-disposition']).toBe(
|
||||
buildAttachmentDisposition('Re_ 견적서_10월.eml'),
|
||||
);
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['content-length']).toBe(
|
||||
String(Buffer.byteLength(RAW_MESSAGE, 'utf8')),
|
||||
);
|
||||
expect((res.body as Buffer).toString('utf8')).toBe(RAW_MESSAGE);
|
||||
});
|
||||
|
||||
it('없는 메일 · 형식이 틀린 id 는 표준 실패 형식의 404 RES_001', async () => {
|
||||
for (const id of [GONE_MESSAGE, 'a%20b']) {
|
||||
const res = await request(server)
|
||||
.get(`/api/messages/${id}/source`)
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 출처 검사(CSRF) — 상태를 바꾸는 요청은 Sec-Fetch-Site(same-origin·none) 또는 허용 목록의 Origin 이어야 한다.
|
||||
* 둘 다 없으면 브라우저가 아닌 클라이언트로 보고 통과시킨다 (docs/mail/api-contract.md 머리말).
|
||||
|
||||
Reference in New Issue
Block a user