From 2cd2764da87d2244d7979ed8b1af2d4072279ea5 Mon Sep 17 00:00:00 2001 From: ttipo Date: Tue, 3 Nov 2026 23:10:44 +0900 Subject: [PATCH] =?UTF-8?q?test(backend):=20jmap.client=20spec=20=E2=80=94?= =?UTF-8?q?=20=EA=B0=80=EC=A7=9C=20=EC=9D=91=EB=8B=B5=20=EC=97=86=EB=8A=94?= =?UTF-8?q?=20fetch=20=EB=8A=94=20=EC=97=B0=EA=B2=B0=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=89=EA=B3=A0=20=EB=B9=A0=EC=A7=84=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9D=84=20=EC=9E=A1=EB=8A=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 이 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 --- .../src/modules/mail/jmap/jmap.client.spec.ts | 92 +++++++++++++++---- 1 file changed, 74 insertions(+), 18 deletions(-) diff --git a/backend/src/modules/mail/jmap/jmap.client.spec.ts b/backend/src/modules/mail/jmap/jmap.client.spec.ts index f475bee..23fe4fb 100644 --- a/backend/src/modules/mail/jmap/jmap.client.spec.ts +++ b/backend/src/modules/mail/jmap/jmap.client.spec.ts @@ -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 => { + 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\] .*대리 접근 권한이 없습니다/,