diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md index a3fe72bc..5849746b 100644 --- a/docs/convenience-relay.md +++ b/docs/convenience-relay.md @@ -24,7 +24,11 @@ CONVENIENCE_ACCESS_CLIENT_SECRET=your_access_client_secret Cloudflare Tunnel에서 `/v1/convenience/.*` 경로를 4320 서버로 전달하고 기존 Access 정책을 적용합니다. Access를 사용하지 않는 로컬 실행에서는 두 Access 변수를 모두 생략합니다. 실제 토큰·키는 `.env`, 비공개 런타임 환경, Worker secrets에만 보관합니다. -GS25 재고는 별도 공식 앱 인증키가 필요합니다. `GS25_API_KEY`를 Worker 또는 Mac 중계 환경에 설정합니다. Worker 키는 인증된 HTTPS 중계의 본문으로 전달되고 공식 API의 `Api-Key` 헤더에만 사용됩니다. Mac 환경 키가 있으면 우선합니다. 키 없음과 원본 401/403은 인증 오류이며, 중계 혼잡·할당량 장애와 구분합니다. 키가 없으면 GS25 상품 검색은 사용할 수 있지만 재고 수량은 복구되지 않습니다. +GS25 재고는 Mac 중계의 `GS25_AUTH_SESSION_FILE`로 정상 로그인 세션을 사용할 수 있습니다. 파일은 현재 사용자 소유의 비공개 권한(0600)으로 보관하고 `accessToken`, `refreshToken`, `deviceId`만 저장합니다. GS 통합회원 또는 네이버 등 정상 로그인으로 GS가 발급한 토큰을 사용하며, 네이버 토큰을 직접 재고 API에 보내지 않습니다. GS 세션은 Worker·응답·로그에 전달하지 않고 고정 GS 재고 API의 Bearer 및 appinfo 헤더에만 사용합니다. 브라우저나 가상기기를 재고 조회마다 실행할 필요가 없습니다. + +Access 토큰 만료1분 전 또는 재고401/403 응답 시 정상 `tokenReissue` API로 갱신합니다. 동시 갱신은1회 공유하고 각 caller의 취소는 다른 caller의 갱신을 끊지 않습니다. 갱신의 자체 기한은10초이며 원래 조회의15초 기한도 유지합니다. 갱신한 두 토큰은 같은 디렉터리의0600 임시파일을 원자교체해 저장합니다. 저장 실패·인증 철회·refresh 만료·재시도 후 인증거절은 실패로 전달하고 재고0을 만들지 않습니다. 인증 철회 후에는 정상 재로그인으로 파일을 교체하고 중계를 재기동합니다. 정상 조회 예산은 기존 원장에 기록하며 갱신은 별도 인증 요청입니다. + +세션파일을 설정하지 않은 기존 `GS25_API_KEY` 방식도 유지합니다. 키를 Worker 또는 Mac 중계 환경에 설정하면 공식 API의 `Api-Key` 헤더에만 사용하며 Mac 환경 키가 우선합니다. 세션파일을 설정했으면 세션 인증을 우선하고 키로 자동 폴백하지 않습니다. 유효 세션·키 없음과 원본401/403은 인증 오류이며 중계 혼잡·할당량 장애와 구분합니다. 상품 검색은 별도 공개 경로입니다. ## 고정 작업 diff --git a/docs/superpowers/plans/2026-10-05-gs25-direct-auth.md b/docs/superpowers/plans/2026-10-05-gs25-direct-auth.md new file mode 100644 index 00000000..850b5cc2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-gs25-direct-auth.md @@ -0,0 +1,10 @@ +# GS25 정상 로그인 세션 직접 API 연결 + +사용자 승인: 운영 반영과 직접 API 인증. 네이버 정상 로그인으로 GS 토큰을 발급했고 실제 앱과 같은 코카콜라캔350ML(8801094017200) 재고16/7/10/0 및 토큰 갱신 후 재조회200을 확인했다. 가상기기를 재고 요청마다 실행하지 않는다. + +1. 로컬 중계 전용 세션파일(0600)의 accessToken/refreshToken/deviceId를 읽는 전송기를 TDD로 구현한다. GS 고정 재고 URL에만 Bearer/appinfo를 적용하고 Worker/로그/응답에 토큰을 전달하지 않는다. +2. 만료1분 전 갱신, 동일 동시 갱신 공유, 인증거절 시1회 갱신·재조회, 원본/갱신의 취소와 기한, 갱신토큰 원자저장 및 실패시 재고0 위장 방지를 검증한다. 기존 API키 방식은 세션파일 미설정 때 유지한다. +3. GS25_AUTH_SESSION_FILE을 Mac 중계 시작 설정에 연결한다. 기존 용량/원장/조회캐시 정책을 유지한다. 별도 auth API를 외부에 노출하지 않는다. +4. 관련 테스트·전체check/coverage/build·독립 리뷰 후 PR 병합한다. 운영 파일 백업·중계 유휴 상태 확인 후 코드/세션파일 설정 적용, 원장 보존·실제 REST/MCP/CLI 재고 및 타서비스 smoke를 검증한다. + +동일 상품·지역의 실재고가 앱과 일치하는 증거와 갱신 성공은 scratch의 private 인증 파일과 공개 검증 JSON에 분리되어 있다. 사용자 자격증명/회원정보/토큰은 커밋하지 않는다. 정상 refresh 만료 또는 계정 인증 철회 시 재로그인이 필요하며 오류로 전달한다. diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts index 13d242e3..0f5cdce4 100644 --- a/scripts/relay/convenience.ts +++ b/scripts/relay/convenience.ts @@ -6,6 +6,7 @@ import * as z from 'zod'; import { CU_API } from '../../src/services/cu/api.js'; import { SEVENELEVEN_API } from '../../src/services/seveneleven/api.js'; import { GS25_API } from '../../src/services/gs25/api.js'; +import type { Gs25SessionTransport } from './gs25-session.js'; import { createHttpRelay, RelayError, @@ -245,7 +246,7 @@ const paths: Record = { }; export function createConvenienceRelay( token: string, - options: Pick & { gs25ApiKey?: string }, + options: Pick & { gs25ApiKey?: string; gs25Session?: Gs25SessionTransport }, ) { return createHttpRelay(token, { ...options, @@ -276,14 +277,14 @@ export function createConvenienceRelay( } if (operation === 'gs25-stock') { const key = options.gs25ApiKey?.trim() || body.apiKey; - if (!key) throw new RelayError(403); - headers['Api-Key'] = String(key); + if (!options.gs25Session && !key) throw new RelayError(403); + if (!options.gs25Session) headers['Api-Key'] = String(key); delete body.apiKey; } if (method === 'GET' || operation === 'seven-popwords') url.search = new URLSearchParams(body as Record).toString(); const response = await abortable( - fetcher(url, { + (operation === 'gs25-stock' && options.gs25Session ? options.gs25Session : fetcher)(url, { method, headers, redirect: 'manual', diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index f504eca5..20523aa1 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -8,6 +8,7 @@ import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createFileQuota, assertSeparateQuotaDirectories } from './quota.js'; import { createConvenienceRelay } from './convenience.js'; +import { createGs25SessionTransport } from './gs25-session.js'; import { createDtryxRelay } from './dtryx.js'; let logger: ReturnType | undefined; @@ -39,7 +40,11 @@ async function main() { await assertSeparateQuotaDirectories(stateDir, convenienceDir); convenienceLog = createRelayLogger(join(convenienceDir, 'logs'), { service: 'convenience' }); const quota = await createFileQuota(join(convenienceDir, 'quota.json')); - const convenienceHandler = createConvenienceRelay(convenienceToken, { takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY }); + const sessionFile = process.env.GS25_AUTH_SESSION_FILE?.trim(); + const convenienceHandler = createConvenienceRelay(convenienceToken, { + takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY, + gs25Session: sessionFile ? createGs25SessionTransport(sessionFile) : undefined, + }); convenience = observeRelay(async (request) => { const response = await convenienceHandler(request); if (response.ok && new URL(request.url).pathname === '/v1/convenience/health') return Response.json({ ...(await response.json()), quota: quota.status(), logging: convenienceLog!.status() }); diff --git a/scripts/relay/gs25-session.ts b/scripts/relay/gs25-session.ts new file mode 100644 index 00000000..c884a587 --- /dev/null +++ b/scripts/relay/gs25-session.ts @@ -0,0 +1,134 @@ +/** GS 정상 로그인 토큰을 로컬에만 보관하고 고정 재고 API에 사용합니다. */ +import { randomUUID } from 'node:crypto'; +import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; +import { GS25_API } from '../../src/services/gs25/api.js'; +import { abortable, readJson, RelayError } from './http-relay.js'; + +interface Session { accessToken: string; refreshToken: string; deviceId: string } +export type Gs25SessionTransport = (url: URL, init: RequestInit) => Promise; + +function expiration(token: unknown): number { + if (typeof token !== 'string' || !/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token)) throw new RelayError(403); + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString('utf8')); + if (!Number.isFinite(payload.exp) || payload.exp <= 0) throw Error(); + return payload.exp * 1000; + } catch { throw new RelayError(403); } +} + +function parseSession(value: unknown): Session { + if (!value || typeof value !== 'object') throw new RelayError(403); + const session = value as Session; + expiration(session.accessToken); + expiration(session.refreshToken); + if (typeof session.deviceId !== 'string' || !/^[\w-]{1,100}$/.test(session.deviceId)) throw new RelayError(403); + return { accessToken: session.accessToken, refreshToken: session.refreshToken, deviceId: session.deviceId }; +} + +function appHeaders(session: Session): Headers { + return new Headers({ + Accept: 'application/json', 'Content-Type': 'application/json', + 'User-Agent': 'woodongs-app/5.3.61 (Android; 14; sdk_gphone64_arm64)', + request_id: randomUUID(), appinfo_device_id: session.deviceId, + appinfo_model_name: 'sdk_gphone64_arm64', appinfo_os_version: '14', + appinfo_app_version: '5.3.61', appinfo_app_build_number: '2132', appinfo_os_type: 'android', + }); +} + +export function createGs25SessionTransport( + file: string, + options: { fetcher?: typeof fetch; now?: () => number; refreshTimeoutMs?: number } = {}, +): Gs25SessionTransport { + const fetcher = options.fetcher ?? fetch; + const now = options.now ?? Date.now; + let loading: Promise | undefined; + let current: Session | undefined; + let refreshing: Promise | undefined; + let revoked = false; + + async function load(): Promise { + if (revoked) throw new RelayError(403); + if (current) return current; + loading ??= (async () => { + try { + const info = await stat(file); + if (!info.isFile() || info.size > 16384 || (info.mode & 0o077) !== 0 || info.uid !== process.getuid!()) throw Error(); + current = parseSession(JSON.parse(await readFile(file, 'utf8'))); + return current; + } catch { throw new RelayError(403); } + })(); + return loading; + } + + async function renew(session: Session): Promise { + if (expiration(session.refreshToken) <= now()) { revoked = true; throw new RelayError(403); } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), options.refreshTimeoutMs ?? 10000); + const temporary = `${file}.${randomUUID()}.tmp`; + try { + const headers = appHeaders(session); + headers.set('Refresh', session.refreshToken); + const response = await abortable(fetcher(new URL('/api/bff/v4/auth/tokenReissue', GS25_API.BFF_BASE_URL), { + method: 'POST', headers, redirect: 'manual', signal: controller.signal, + }), controller.signal); + if (!response.ok) { + void response.body?.cancel().catch(() => undefined); + if ([401, 403].includes(response.status)) { revoked = true; throw new RelayError(403); } + throw new RelayError(502); + } + const value = await readJson(response.body, 131072, controller.signal, 502) as { resultCode?: string; data?: { access?: string; refresh?: string } }; + if (value.resultCode !== '0000' || !value.data?.access || !value.data.refresh) throw new RelayError(502); + let next: Session; + try { + next = parseSession({ accessToken: value.data.access.replace(/^Bearer /, ''), refreshToken: value.data.refresh.replace(/^Bearer /, ''), deviceId: session.deviceId }); + if (expiration(next.accessToken) <= now() + 60000 || expiration(next.refreshToken) <= now()) throw Error(); + } catch { throw new RelayError(502); } + // 회전된 refresh 토큰은 파일 저장이 성공한 뒤 사용합니다. + try { + await abortable((async () => { + await writeFile(temporary, JSON.stringify(next), { mode: 0o600, flag: 'wx', signal: controller.signal }); + if (controller.signal.aborted) throw new RelayError(504); + await rename(temporary, file); + if (controller.signal.aborted) throw new RelayError(504); + })(), controller.signal); + } catch { revoked = true; throw new RelayError(502); } + current = next; + return next; + } catch (error) { + if (controller.signal.aborted) throw new RelayError(504); + if (error instanceof RelayError) throw error; + throw new RelayError(502); + } finally { + clearTimeout(timer); + void unlink(temporary).catch(() => undefined); + } + } + + async function ready(forceFor?: string): Promise { + const session = await load(); + if ((!forceFor && expiration(session.accessToken) > now() + 60000) || (forceFor && session.accessToken !== forceFor)) return session; + if (!refreshing) refreshing = renew(session).finally(() => { refreshing = undefined; }); + return refreshing; + } + + return async (url, init) => { + if (url.origin !== GS25_API.BFF_BASE_URL || url.pathname !== GS25_API.STORE_STOCK_PATH || url.username || url.password) throw new RelayError(400); + const signal = init.signal ?? new AbortController().signal; + if (signal.aborted) throw new RelayError(499); + const request = async (session: Session) => { + const headers = new Headers(init.headers); + headers.delete('Api-Key'); + appHeaders(session).forEach((value, key) => headers.set(key, value)); + headers.set('Authorization', `Bearer ${session.accessToken}`); + return abortable(fetcher(url, { ...init, method: 'GET', body: undefined, headers, redirect: 'manual', signal }), signal); + }; + const session = await abortable(ready(), signal); + const response = await request(session); + if (![401, 403].includes(response.status)) return response; + void response.body?.cancel().catch(() => undefined); + const next = await abortable(ready(session.accessToken), signal); + const retry = await request(next); + if ([401, 403].includes(retry.status)) revoked = true; + return retry; + }; +} diff --git a/tests/relay/convenience-gs25-session.test.ts b/tests/relay/convenience-gs25-session.test.ts new file mode 100644 index 00000000..61049f19 --- /dev/null +++ b/tests/relay/convenience-gs25-session.test.ts @@ -0,0 +1,26 @@ +import { expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +import { RelayError } from '../../scripts/relay/http-relay.js'; + +const request = () => new Request('http://localhost/v1/convenience/gs25-stock', { + method: 'POST', headers: { Authorization: 'Bearer convenience' }, + body: JSON.stringify({ serviceCode: '01', realTimeStockYn: 'Y', itemCode: '1' }), +}); +it('세션 전송이 있으면 Worker API키 없이 재고를 조회한다', async () => { + const fetcher = vi.fn(); + const session = vi.fn(async () => Response.json({ stores: [{ realStockQuantity: '16' }] })); + const quota = vi.fn(async () => true); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: quota, gs25Session: session }); + expect((await relay(request())).status).toBe(200); + expect((await relay(request())).status).toBe(200); + expect(fetcher).not.toHaveBeenCalled(); + expect(session).toHaveBeenCalledTimes(1); + expect(quota).toHaveBeenCalledTimes(1); +}); +it('세션 갱신 오류는 캐시하거나 재고0으로 바꾸지 않는다', async () => { + const session = vi.fn(async () => { throw new RelayError(403); }); + const relay = createConvenienceRelay('convenience', { takeQuota: async () => true, gs25Session: session }); + expect((await relay(request())).status).toBe(403); + expect((await relay(request())).status).toBe(403); + expect(session).toHaveBeenCalledTimes(2); +}); diff --git a/tests/relay/gs25-session.test.ts b/tests/relay/gs25-session.test.ts new file mode 100644 index 00000000..424b5d6a --- /dev/null +++ b/tests/relay/gs25-session.test.ts @@ -0,0 +1,196 @@ +import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createGs25SessionTransport } from '../../scripts/relay/gs25-session.js'; +import * as fs from 'node:fs/promises'; + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + return { ...actual, writeFile: vi.fn(actual.writeFile), rename: vi.fn(actual.rename) }; +}); + +const now = 1800000000000; +const jwt = (exp: number) => `e30.${Buffer.from(JSON.stringify({ exp })).toString('base64url')}.fixture`; +const valid = jwt(now / 1000 + 3600); +const renewed = jwt(now / 1000 + 7200); +const refresh = jwt(now / 1000 + 9000); +const url = new URL('https://b2c-bff.woodongs.com/api/bff/v2/store/stock?itemCode=1'); +const dirs: string[] = []; +async function session(accessToken = valid) { + const dir = await mkdtemp(join(tmpdir(), 'gs25-session-')); + dirs.push(dir); + const file = join(dir, 'auth.json'); + await writeFile(file, JSON.stringify({ accessToken, refreshToken: refresh, deviceId: 'device' }), { mode: 0o600 }); + return file; +} +afterEach(async () => { vi.restoreAllMocks(); await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); }); +const reply = () => Response.json({ stores: [{ storeCode: 's', realStockQuantity: '16' }] }); +const fresh = () => Response.json({ resultCode: '0000', data: { access: renewed, refresh } }); + +describe('GS 정상 로그인 세션 전송', () => { + it('고정 재고 API에만 정상 Bearer·appinfo를 붙이고 키를 보내지 않는다', async () => { + const fetcher = vi.fn(async () => reply()); + const send = createGs25SessionTransport(await session(), { fetcher, now: () => now }); + const response = await send(url, { headers: { 'Api-Key': 'legacy' } }); + expect(response.status).toBe(200); + const headers = new Headers(fetcher.mock.calls[0]![1]?.headers); + expect(headers.get('Authorization')).toBe(`Bearer ${valid}`); + expect(headers.get('appinfo_device_id')).toBe('device'); + expect(headers.get('Api-Key')).toBeNull(); + await expect(send(new URL('https://other.invalid/'), {})).rejects.toMatchObject({ status: 400 }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it('만료 임박 토큰을 한 번 갱신하고 새 토큰을0600 파일에 저장한다', async () => { + const file = await session(jwt(now / 1000 + 20)); + const fetcher = vi.fn().mockImplementation(async (input) => String(input).includes('tokenReissue') ? fresh() : reply()); + const send = createGs25SessionTransport(file, { fetcher, now: () => now }); + await Promise.all([send(url, {}), send(url, {})]); + expect(fetcher.mock.calls.filter(([input]) => String(input).includes('tokenReissue'))).toHaveLength(1); + const [input, init] = fetcher.mock.calls[0]!; + expect(String(input)).toBe('https://b2c-bff.woodongs.com/api/bff/v4/auth/tokenReissue'); + expect(init?.method).toBe('POST'); + expect(new Headers(init?.headers).get('Refresh')).toBe(refresh); + expect(JSON.parse(await readFile(file, 'utf8')).accessToken).toBe(renewed); + expect((await stat(file)).mode & 0o777).toBe(0o600); + }); + it('원본401은 한 번만 갱신하며 두 번째 거절은 실패로 전달한다', async () => { + const fetcher = vi.fn().mockImplementation(async (input) => String(input).includes('tokenReissue') ? fresh() : new Response(null, { status: 401 })); + const send = createGs25SessionTransport(await session(), { fetcher, now: () => now }); + expect((await send(url, {})).status).toBe(401); + expect(fetcher).toHaveBeenCalledTimes(3); + }); + it('갱신401 또는 잘못된 성공 JSON을 재고0으로 반환하지 않는다', async () => { + for (const response of [new Response(null, { status: 401 }), Response.json({ resultCode: '0000', data: { access: 'bad', refresh } })]) { + const fetcher = vi.fn(async () => response); + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher, now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: response.status === 401 ? 403 : 502 }); + expect(fetcher).toHaveBeenCalledTimes(1); + } + }); + it('한 caller 취소가 다른 caller의 공유 갱신을 끊지 않는다', async () => { + let complete!: (response: Response) => void; + const fetcher = vi.fn().mockImplementation(async (input) => String(input).includes('tokenReissue') ? new Promise((resolve) => { complete = resolve; }) : reply()); + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher, now: () => now }); + const controller = new AbortController(); + const first = send(url, { signal: controller.signal }); + const observed = expect(first).rejects.toMatchObject({ status: 499 }); + const second = send(url, {}); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + controller.abort(); + await observed; + complete(fresh()); + expect((await second).status).toBe(200); + }); + it('갱신이 취소 신호를 무시해도 자체 기한 안에 실패한다', async () => { + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher: async () => new Promise(() => {}), now: () => now, refreshTimeoutMs: 10 }); + await expect(send(url, {})).rejects.toMatchObject({ status: 504 }); + }); + it('공개 권한 파일이나 잘못된 세션은 원본 호출 전에 거절한다', async () => { + const file = await session(); + await writeFile(file, '{}'); + const fetcher = vi.fn(async () => reply()); + const send = createGs25SessionTransport(file, { fetcher, now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + expect(fetcher).not.toHaveBeenCalled(); + const publicFile = await session(); + await chmod(publicFile, 0o644); + await expect(createGs25SessionTransport(publicFile, { fetcher })(url, {})).rejects.toMatchObject({ status: 403 }); + }); + it('이미 취소된 요청은 갱신과 재고 호출을 시작하지 않는다', async () => { + const fetcher = vi.fn(async () => fresh()); + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher, now: () => now }); + await expect(send(url, { signal: AbortSignal.abort() })).rejects.toMatchObject({ status: 499 }); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('토큰 저장이 지연돼도 갱신 기한 뒤 성공하거나 후속 caller를 묶지 않는다', async () => { + const file = await session(jwt(now / 1000 - 1)); + let finish!: () => void; + vi.mocked(fs.writeFile).mockImplementationOnce(async () => new Promise((resolve) => { finish = resolve; })); + const fetcher = vi.fn(async () => fresh()); + const send = createGs25SessionTransport(file, { fetcher, now: () => now, refreshTimeoutMs: 15 }); + await expect(send(url, {})).rejects.toMatchObject({ status: 504 }); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + finish(); + await Promise.resolve(); + expect(JSON.parse(await readFile(file, 'utf8')).accessToken).not.toBe(renewed); + expect(fs.rename).not.toHaveBeenCalled(); + }); + it.each([null, { accessToken: 'bad' }, { accessToken: jwt(0), refreshToken: refresh, deviceId: 'device' }, + { accessToken: valid, refreshToken: refresh, deviceId: '\r\n' }])('손상된 로컬 세션을 거절한다 %j', async (value) => { + const file = await session(); + await writeFile(file, JSON.stringify(value)); + await expect(createGs25SessionTransport(file)(url, {})).rejects.toMatchObject({ status: 403 }); + }); + it('기본 fetch와 시계를 사용해 갱신 없이 조회한다', async () => { + const fetcher = vi.fn(async () => reply()); + vi.stubGlobal('fetch', fetcher); + try { expect((await createGs25SessionTransport(await session())(url, {})).status).toBe(200); } + finally { vi.unstubAllGlobals(); } + }); + it('만료된 refresh는 서버 호출 없이 재로그인 오류로 전달한다', async () => { + const file = await session(jwt(now / 1000 - 1)); + await writeFile(file, JSON.stringify({ accessToken: valid, refreshToken: jwt(now / 1000 - 1), deviceId: 'device' })); + const fetcher = vi.fn(async () => new Response(null, { status: 401 })); + const send = createGs25SessionTransport(file, { fetcher, now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it.each([403, 500])('갱신 HTTP%d은 성공으로 위장하지 않는다', async (status) => { + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { + fetcher: async () => new Response('failure', { status }), now: () => now, + }); + await expect(send(url, {})).rejects.toMatchObject({ status: status === 403 ? 403 : 502 }); + }); + it.each([{ resultCode: 'bad' }, { resultCode: '0000' }, { resultCode: '0000', data: {} }, + { resultCode: '0000', data: { access: renewed } }, + { resultCode: '0000', data: { access: jwt(now / 1000 + 5), refresh } }, + { resultCode: '0000', data: { access: renewed, refresh: jwt(now / 1000 - 5) } }])('잘못된 갱신 envelope·기한은 실패한다 %j', async (value) => { + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher: async () => Response.json(value), now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 502 }); + }); + it('갱신 네트워크 오류를 비밀 없는 오류로 반환한다', async () => { + const send = createGs25SessionTransport(await session(jwt(now / 1000 - 1)), { fetcher: async () => { throw new Error('private value'); }, now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 502, message: 'Relay failure' }); + }); + it('갱신 저장 실패 뒤에는 재인증할 때까지 원본을 호출하지 않는다', async () => { + const file = await session(jwt(now / 1000 - 1)); + vi.mocked(fs.rename).mockRejectedValueOnce(new Error('disk failure')); + const fetcher = vi.fn(async () => fresh()); + const send = createGs25SessionTransport(file, { fetcher, now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 502 }); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + expect(fetcher).toHaveBeenCalledTimes(1); + }); + it('서로 다른 동시 조회의401은 토큰을 한 번만 회전한다', async () => { + const fetcher = vi.fn().mockImplementation(async (input, init) => { + if (String(input).includes('tokenReissue')) return fresh(); + if (new Headers(init?.headers).get('Authorization') === `Bearer ${valid}`) return new Response(null, { status: 401 }); + return reply(); + }); + const send = createGs25SessionTransport(await session(), { fetcher, now: () => now }); + const result = await Promise.all([send(url, {}), send(url, {})]); + expect(result.map((r) => r.status)).toEqual([200, 200]); + expect(fetcher.mock.calls.filter(([input]) => String(input).includes('tokenReissue'))).toHaveLength(1); + }); + it('인증실패 응답의 body 취소 실패도 인증 오류를 바꾸지 않는다', async () => { + const denied = () => { + const response = new Response('denied', { status: 401 }); + vi.spyOn(response.body!, 'cancel').mockRejectedValueOnce(new Error('already consumed')); + return response; + }; + const send = createGs25SessionTransport(await session(), { fetcher: async () => denied(), now: () => now }); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + await new Promise((resolve) => setImmediate(resolve)); + }); + it('rename 완료가 기한보다 늦으면 메모리 세션을 갱신하지 않는다', async () => { + const file = await session(jwt(now / 1000 - 1)); + let complete!: () => void; + vi.mocked(fs.rename).mockImplementationOnce(async () => new Promise((resolve) => { complete = resolve; })); + const send = createGs25SessionTransport(file, { fetcher: async () => fresh(), now: () => now, refreshTimeoutMs: 15 }); + await expect(send(url, {})).rejects.toMatchObject({ status: 504 }); + complete(); + await new Promise((resolve) => setImmediate(resolve)); + await expect(send(url, {})).rejects.toMatchObject({ status: 403 }); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 37acf223..57fc1203 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts'], + include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/gs25-session.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts'], // 커버리지 제외 파일 exclude: [