generated from ttipo/project-base
test(backend): jmap.client spec — 가짜 응답 없는 fetch 는 연결 실패로 막고 빠진 응답을 잡는다
이 spec 은 fetch 를 spyOn 으로 감싸기만 해, 가짜 응답을 넣지 않은 호출이 진짜 http://mail:8080 으로 나갔다. 13단계부터 메일 계정이 없는 세션은 계정 정보를 한 번 더 묻는데, 그 요청이 CI 컨테이너에서 이름 풀기를 기다리다 테스트 5초를 넘겼다 (2026-11-03 CI — 개발 컨테이너에서는 개발 엔진이 실제로 받았다). - 가짜 응답이 없는 fetch 는 곧바로 연결 실패로 거절하고 주소를 남긴다 — afterEach 가 남은 주소가 있으면 그 테스트를 실패시킨다. jest-mock 30 의 mockReset 은 구현까지 지우므로 resetFetchMock 이 기본을 다시 건다. - 메일 계정이 없는 세션 · 실시간 연결 403 두 테스트에 계정 정보 응답을 넣고 요청을 단언. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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\] .*대리 접근 권한이 없습니다/,
|
||||
|
||||
Reference in New Issue
Block a user