fix(docker): mail setup 받는 메일 크기 한도 50MiB · 전환 준비 문서 (mailcow 확인 · 서버 B 배포 · 백업 · 큰 메일) #12

Merged
ttipo merged 14 commits from feat/mail-migration-password into main 2026-11-04 07:59:25 +09:00
Showing only changes of commit 2cd2764da8 - Show all commits
@@ -80,10 +80,40 @@ function decodeBasic(header: string): string {
return Buffer.from(header.replace(/^Basic /, ''), 'base64').toString('utf8');
}
/** Node fetch 의 연결 실패와 같은 모양 — 엔진에 닿지 못했다 */
function connectionRefused(): TypeError {
return new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
});
}
describe('JmapClient', () => {
let fetchMock: jest.SpyInstance;
let cache: Cache;
let client: JmapClient;
/** 가짜 응답 없이 나간 fetch 의 주소 — 테스트가 끝날 때 비어 있어야 한다 (afterEach) */
let unansweredFetches: string[];
/**
* 가짜 응답을 넣지 않은 fetch 의 기본 — 진짜 네트워크로 나가지 않고 곧바로 연결 실패로 거절하며 주소를 남긴다.
* spy 의 기본(원래 fetch)이면 spec 의 엔진 주소 http://mail:8080 으로 진짜 요청이 나간다 — 개발 컨테이너에서는 개발 엔진이 받고,
* CI 에서는 그 이름을 늦게 풀면 테스트 제한 시간(5초)을 넘긴다 (2026-11-03 CI — 메일 계정이 없는 세션의 계정 정보 조회)
*/
const refuseUnanswered = (url: string): Promise<Response> => {
unansweredFetches.push(url);
return Promise.reject(connectionRefused());
};
/**
* 넣어 둔 가짜 응답 · 기록을 비우고 기본(연결 실패)을 다시 건다 — `fetchMock.mockReset()` 을 바로 부르지 않는다.
* jest-mock 30 의 mockReset 은 spy 의 구현까지 지워, 그 뒤로는 기본도 원래 fetch 도 아닌 undefined 를 돌려준다
*/
const resetFetchMock = (): void => {
fetchMock.mockReset();
fetchMock.mockImplementation(refuseUnanswered);
};
/** n 번째 fetch 호출의 URL·헤더 */
const requestAt = (index: number) => {
@@ -96,7 +126,9 @@ describe('JmapClient', () => {
};
beforeEach(() => {
unansweredFetches = [];
fetchMock = jest.spyOn(global, 'fetch');
fetchMock.mockImplementation(refuseUnanswered);
cache = fakeCache();
client = new JmapClient(settings, cache);
});
@@ -104,6 +136,8 @@ describe('JmapClient', () => {
afterEach(() => {
fetchMock.mockRestore();
jest.useRealTimers();
// 엔진에 묻는 일마다 응답을 직접 넣는다 — 빠진 응답이 있으면 연결 실패로 지나가 버리지 않게 여기서 그 테스트를 실패시킨다
expect(unansweredFetches).toEqual([]);
});
describe('직원 자격 확인 (verifyCredentials)', () => {
@@ -157,13 +191,7 @@ describe('JmapClient', () => {
'MAIL_001',
);
fetchMock.mockRejectedValueOnce(
new TypeError('fetch failed', {
cause: Object.assign(new Error('connect ECONNREFUSED'), {
code: 'ECONNREFUSED',
}),
}),
);
fetchMock.mockRejectedValueOnce(connectionRefused());
expect(await codeOf(client.verifyCredentials(ARA, 'pw'))).toBe(
'MAIL_001',
);
@@ -288,7 +316,7 @@ describe('JmapClient', () => {
it('지금 비밀번호가 틀리면 세션 요청이 401 — 바꾸기를 보내지 않고 current-password-rejected (403 도 로그인 확인처럼 같다)', async () => {
for (const status of [401, 403]) {
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json({}, status));
await expect(client.changePassword(ARA, 'wrong', NEXT)).resolves.toBe(
@@ -465,7 +493,7 @@ describe('JmapClient', () => {
'REQ_001',
);
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockResolvedValueOnce(json(sessionBody));
expect(await codeOf(client.changePassword(ARA, CURRENT, NEXT))).toBe(
'MAIL_001',
@@ -1038,12 +1066,32 @@ describe('JmapClient', () => {
expect(cache.set).not.toHaveBeenCalled();
});
it('메일 계정이 없는 세션은 MAIL_001', async () => {
fetchMock.mockResolvedValueOnce(
json({ ...sessionBody, primaryAccounts: {} }),
);
it('메일 계정이 없는 세션은 MAIL_001 — 먼저 대리 접근으로 계정 정보(권한)를 한 번 더 묻고, 비밀번호 만료의 모양이 아닐 때 (13단계)', async () => {
fetchMock
.mockResolvedValueOnce(json({ ...sessionBody, primaryAccounts: {} }))
// 보통 직원의 권한 — 메일 권한이 있어 비밀번호 만료가 아니다
.mockResolvedValueOnce(
json({
permissions: ['authenticate', 'emailReceive', 'jmapEmailGet'],
}),
);
expect(await codeOf(client.getSession(ARA))).toBe('MAIL_001');
const failure = await client
.getSession(ARA)
.catch((error: unknown) => error);
expect(failure).not.toBeInstanceOf(PasswordExpiredException);
expect((failure as BusinessException).code).toBe('MAIL_001');
// 만료가 아니니 세션이 모자란 것으로 — 계정 정보를 읽지 못해서가 아니다
expect((failure as BusinessException).context).toMatchObject({
reason: 'session-incomplete',
});
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(requestAt(1).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(1).authorization)).toBe(
`${ARA}%mail-gateway@arasystem.test:gateway-secret`,
);
expect(cache.set).not.toHaveBeenCalled();
});
});
@@ -1291,7 +1339,7 @@ describe('JmapClient', () => {
it('제한 시간을 받으면 세션 조회(캐시에 없을 때)까지 그 안에서 한다 — 세션 조회에 쓴 시간만큼 호출이 짧아진다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((resolve, reject) => {
@@ -1326,7 +1374,7 @@ describe('JmapClient', () => {
it('세션 조회가 받은 제한 시간을 넘기면 거기서 MAIL_001 (timeout) — 호출은 보내지 않는다', async () => {
jest.useFakeTimers();
// 이 describe 의 beforeEach 가 넣어 둔 세션 응답을 지운다 — 세션 조회도 아래 구현을 따르게
fetchMock.mockReset();
resetFetchMock();
fetchMock.mockImplementation(
(_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
@@ -1672,7 +1720,7 @@ describe('JmapClient', () => {
expect(requestAt(0).url).toBe('http://mail:8080/jmap/session');
expect(requestAt(1).url).toContain('/jmap/eventsource/');
fetchMock.mockReset();
resetFetchMock();
// 부를 때마다 새 응답 — Response 본문은 한 번만 읽힌다
fetchMock.mockImplementation(() =>
Promise.resolve(json(withoutEventSource)),
@@ -1705,13 +1753,21 @@ describe('JmapClient', () => {
fetchMock
.mockResolvedValueOnce(json(sessionBody))
.mockResolvedValueOnce(json({}, 403));
.mockResolvedValueOnce(json({}, 403))
// 게이트웨이 자신의 권한에 impersonate 가 없다 — 설정 문제
.mockResolvedValueOnce(json({ permissions: ['authenticate'] }));
const code = await runWithRequestContext({ requestId: 'trace-es' }, () =>
codeOf(
client.openEventSource(ARA, OPTIONS, new AbortController().signal),
),
);
expect(code).toBe('MAIL_001');
// 게이트웨이 자신의 권한을 읽었다(본인 Basic — 대리 접근이 아니다)
expect(fetchMock).toHaveBeenCalledTimes(6);
expect(requestAt(5).url).toBe('http://mail:8080/api/account');
expect(decodeBasic(requestAt(5).authorization)).toBe(
'mail-gateway@arasystem.test:gateway-secret',
);
expect(errorLog).toHaveBeenCalledWith(
expect.stringMatching(
/^\[MAIL_001\]\[trace-es\] .*대리 접근 권한이 없습니다/,