generated from ttipo/project-base
feat(backend): 12단계 계정 보안 — 2단계 로그인 · 2FA · 앱 비밀번호 · 세션 끊기 · 로그인 기록 · 엔진 웹훅
엔진(Stalwart 0.16.27)의 TOTP 와 앱 비밀번호를 쓰되, 엔진이 하지 않는 것을 서버가 맡는다. - 등록 때 첫 코드 확인, 성공에 쓴 코드 2분 막기(확인 중인 같은 코드도), 코드 실패 한도(15분 5 · 24시간 20) - 웹메일 로그인 · 비밀번호 바꾸기에서 앱 비밀번호 모양 거절, 앱 비밀번호는 메일 프로그램 권한만 - 엔진 Basic 의 402 를 장애(MAIL_001)로 다루던 것을 고쳐 코드 단계로 — 402 면 정식 주소로 한도 · 기록 - 비밀번호를 바꾸면 앱 비밀번호도 지우고, 끊은 세션은 액세스 토큰의 sid 로 바로 막는다 - 메일 프로그램 로그인은 엔진 웹훅(소켓 주소 · HMAC · 내용 지문 · 1,000행씩)으로 받아 90일 남긴다 - 요청 대상에 날 \ · # 가 있으면 라우팅 전에 400 — Express 가 이를 다시 읽어 앞단이 닫은 경로가 열렸다 에러 코드 AUTH_006 ~ AUTH_009 와 웹 · 앱 사전, 마이그레이션 AddAccountSecurity 를 함께 넣었다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { createHash, randomBytes, randomUUID } from 'crypto';
|
||||
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';
|
||||
@@ -15,8 +16,20 @@ import { DataSource } from 'typeorm';
|
||||
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,
|
||||
@@ -90,6 +103,7 @@ import type {
|
||||
MessageSummary,
|
||||
MessageUpdate,
|
||||
OutgoingMessage,
|
||||
CredentialCheck,
|
||||
PasswordChangeOutcome,
|
||||
PendingSend,
|
||||
RecipientRejectionReason,
|
||||
@@ -104,7 +118,9 @@ 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 { createCidrMatcher, parseCidr } from '@/shared/utils/cidr';
|
||||
import { buildAttachmentDisposition } from '@/shared/utils/content-disposition';
|
||||
import { totpCode, totpCounterAt, verifyTotp } from '@/shared/utils/totp';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
@@ -279,6 +295,46 @@ const PROFILE_NAMES = new Map([
|
||||
['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;
|
||||
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
|
||||
@@ -584,6 +640,87 @@ describe('응답 계약 (e2e)', () => {
|
||||
},
|
||||
};
|
||||
|
||||
/** 12단계 — 대역 엔진의 2단계 인증(직원 → otpauth 주소) · 앱 비밀번호(직원마다) */
|
||||
const twoFactorUrls = new Map<string, 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;
|
||||
@@ -775,11 +912,33 @@ describe('응답 계약 (e2e)', () => {
|
||||
email,
|
||||
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
}),
|
||||
verifyCredentials: async (email: string, password: string) => {
|
||||
verifyCredentials: async (
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<CredentialCheck> => {
|
||||
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
|
||||
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
|
||||
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
|
||||
return password === CORRECT_PASSWORD ? email : null;
|
||||
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)
|
||||
@@ -964,7 +1123,21 @@ describe('응답 계약 (e2e)', () => {
|
||||
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');
|
||||
@@ -1339,6 +1512,12 @@ describe('응답 계약 (e2e)', () => {
|
||||
.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)
|
||||
.compile();
|
||||
|
||||
// CI 의 빈 DB 에도 표가 있어야 한다 — 운영처럼 마이그레이션으로 만든다 (synchronize 를 켜지 않는다)
|
||||
@@ -1381,6 +1560,10 @@ describe('응답 계약 (e2e)', () => {
|
||||
// 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',
|
||||
]) {
|
||||
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
|
||||
}
|
||||
@@ -6055,8 +6238,6 @@ describe('응답 계약 (e2e)', () => {
|
||||
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\\public-links\\${token}#a`, portalHost],
|
||||
[`/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],
|
||||
@@ -6068,6 +6249,19 @@ describe('응답 계약 (e2e)', () => {
|
||||
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 });
|
||||
@@ -6584,6 +6778,701 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 출처 검사(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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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,
|
||||
});
|
||||
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 });
|
||||
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('출처 검사 (상태 변경 요청)', () => {
|
||||
const EVIL_ORIGIN = 'https://evil.example';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user