diff --git a/docs/oliveyoung-http-relay.md b/docs/oliveyoung-http-relay.md new file mode 100644 index 00000000..1324b1a5 --- /dev/null +++ b/docs/oliveyoung-http-relay.md @@ -0,0 +1,37 @@ +# 올리브영 HTTP 중계 + +브라우저를 실행하지 않고 맥에서 공식 조회 API를 호출하는 선택 모드입니다. 기존 REST/MCP/CLI와 중계 인증·캐시·호출 한도·원장을 유지합니다. 맥의 전원·네트워크·중계 프로세스는 계속 필요합니다. 기존 GUI LaunchAgent의 로그인 시작 조건까지 변경하는 기능은 아닙니다. + +## 실행 설정 + +Python3 표준 라이브러리만 사용하며 별도 pip 패키지나 쿠키 파일은 필요하지 않습니다. 아래 두 값을 기존 비공개 relay.env에 추가합니다. + +```sh +OY_RELAY_TRANSPORT=http +OY_HTTP_PYTHON=/opt/homebrew/bin/python3 +``` + +Python 경로는 설치 환경에 맞춥니다. 설정을 생략하면 기존 browser 모드이며, http 모드가 실패해도 자동으로 브라우저를 띄우지 않습니다. 운영 토큰이나 개인 프로필은 Python 프로세스에 전달하지 않습니다. + +## 확인할 기능 + +- 상품 검색: product-search-v3 +- 매장 검색: find-store +- 상품 식별자 조회: stock-goods-info-v3 +- 매장별 재고: stock-stores + +중계 health에서 `transport: http`, `browserRequired: false`, `pages: 0`, `state: ready`를 확인합니다. ready는 실행 준비 상태이며 공식 서비스의 모든 요청 성공을 보장하는 표시는 아닙니다. 실제 상품·매장·재고도 함께 검증해야 합니다. + +고정 공식 주소와 네 경로만 허용합니다. 리다이렉트, HTTP200이 아닌 응답, 잘못된 JSON/성공 형식, 응답 용량 초과 및 시간 초과는 실패로 반환합니다. 차단 HTML을 재고0으로 바꾸지 않습니다. 네트워크 읽기15초, 자식 프로세스18초, 응답2MiB 상한이며 별도 외부 요청 재시도는 하지 않습니다. + +## 전환·되돌리기 + +원장과 비밀 설정을 먼저 권한600인 비공개 위치에 백업합니다. 진행 요청이 없을 때 기존 중계를 정상 종료하고 소유 브라우저의 종료를 확인한 뒤 코드와 설정을 반영합니다. quota.json이나 인증 값을 초기화하지 않습니다. + +HTTP 조회가 차단되면 원인을 확인하고 `OY_RELAY_TRANSPORT=browser`로 변경해 중계를 재시작할 수 있습니다. 브라우저 모드에는 기존 GUI 환경과 Playwright 설치가 필요합니다. 쿠키나 브라우저 인증을 HTTP 모드에 복사하여 재사용하지 않습니다. + +## 2026-10-06 실측 + +같은 맥에서 쿠키 없는 Python urllib는 네 API의 HTTP200/SUCCESS를 반환했고 Node fetch·Node https·기본 curl은 403을 받았습니다. Python도 필수 헤더를 제거하면403이었습니다. JSON 공백/문자 인코딩 변경은 원인이 아니었으며 정확한 차단 기준을 TLS만으로 확정하지 않습니다. + +브라우저 중계와 동일 조건에서 상품 코드·상세 식별자·매장 코드·실제 재고 필드를 대조했습니다. 명동2가점 핸드크림 일반9/오늘드림7, 선크림 일반54/오늘드림43 및 픽업/취급 상태가 일치했습니다. 이 수량은 확인 당시 관측값이며 이후 변할 수 있습니다. HTTP 성공 여부는 제공자 정책·네트워크·클라이언트 버전에 따라 달라질 수 있으므로 장기간 무차단을 보장하지 않습니다. diff --git a/docs/superpowers/plans/2026-10-06-oliveyoung-http.md b/docs/superpowers/plans/2026-10-06-oliveyoung-http.md new file mode 100644 index 00000000..69c4e657 --- /dev/null +++ b/docs/superpowers/plans/2026-10-06-oliveyoung-http.md @@ -0,0 +1,14 @@ +# 올리브영 HTTP 중계 구현 계획 + +**Goal:** 기존 맥 중계에서 브라우저 없이 네 API 조회. +**Architecture:** 고정 Python urllib helper와 Node lifecycle을 추가하고 start의 명시적인 http/browser 선택만 변경. +**Tech Stack:** Node execFile, Python3 stdlib, Vitest, unittest. + +- [ ] tests/relay/http-lifecycle.test.ts tests/relay/transport-lifecycle.test.ts tests/relay/oliveyoung-http-python.test.ts와 Python helper 오프라인 fixture 테스트에서 성공·403·redirect·size·malformed·timeout·invalid path·close를 먼저 실패시킨다. 실행: npx vitest run tests/relay/http-lifecycle.test.ts tests/relay/transport-lifecycle.test.ts tests/relay/oliveyoung-http-python.test.ts; python3 tests/relay/test_oliveyoung_http.py. +- [ ] scripts/relay/oliveyoung-http.py에 고정 경로/헤더/200 SUCCESS 검증 및2MiB/15초 제한을 구현한다. +- [ ] scripts/relay/http-lifecycle.ts에 execFile18초 제한, 비밀 없는 환경, ready/active/closed health를 구현하고 위 테스트를 통과시킨다. +- [ ] scripts/relay/transport-lifecycle.ts에 모드선택을 추가하고 start.ts를 연결한다. http는 browser import/launch를 하지 않으며 기존 browser default 유지. 알 수 없는 모드 거부. +- [ ] vitest.config.ts coverage 대상에 새 TypeScript 모듈을 추가하고 npm run check; npm run test:coverage; npm run build; npm audit 실행. 새로운 측정 대상도100%. +- [ ] docs/oliveyoung-http-relay.md에 운영 설정·실측·실패/rollback 조건을 기록하고 독립 리뷰를 수행한다. +- [ ] PR 생성·CI확인·병합 후 원장과 인증을 보존하여 맥 runtime에 코드와 OY_RELAY_TRANSPORT/http 및 OY_HTTP_PYTHON을 반영. 사전 백업과 진행 요청0·소유 브라우저 종료 확인. +- [ ] 운영 REST/MCP/공개CLI 상품·매장·재고 일치 및 최종health/http/pages0을 검증하고 프로젝트 기록·완료 알림을 남긴다. Worker/CLI 코드 변화는 없어 새CLI 버전 발행은 필요하지 않다. diff --git a/docs/superpowers/specs/2026-10-06-oliveyoung-http-design.md b/docs/superpowers/specs/2026-10-06-oliveyoung-http-design.md new file mode 100644 index 00000000..42c48c67 --- /dev/null +++ b/docs/superpowers/specs/2026-10-06-oliveyoung-http-design.md @@ -0,0 +1,13 @@ +# 올리브영 브라우저 없는 맥 중계 + +목표: 기존 상품·매장·상세·매장재고 계약을 유지하며 브라우저 없는 실행을 지원한다. + +실측: 같은 맥의 Python3 urllib는 쿠키 없이 네 API SUCCESS, Node fetch/https와 curl은 403. 최소 헤더는 Python도 403. 헤더가 필요하며 HTTP 클라이언트에 따라 차이가 있다. TLS만 원인이라고 확정하지 않는다. + +선택: 기존 Mac 중계의 수송만 Python 표준 HTTP 클라이언트로 교체하는 명시적인 OY_RELAY_TRANSPORT=http 옵션. 기본 browser는 유지하여 기존 설치 호환 및 롤백 제공. 자동 브라우저 fallback은 하지 않는다. Worker 직접 전환은 같은 환경 검증 근거가 부족하고, 자동 fallback은 브라우저 제거 목표를 충족하지 않는다. + +Node가 고정 Python helper를 실행한다. 외부 Python 패키지·쿠키·개인 프로필 없음. 고정 origin과 네 path만 허용, HTTP200/SUCCESS 및 객체 data만 허용. 리다이렉트 거부, 응답2MiB/네트워크15초/프로세스18초 상한. 비밀 환경 변수를 자식에게 전달하지 않는다. 오류는 재고0이 아닌 기존502로 반환한다. + +start.ts는 mode에 맞는 lifecycle을 선택하고 HTTP일 때 브라우저 모듈을 로드하지 않는다. 기존 인증/캐시/coalescing/직렬처리/원장/한도/로그는 유지한다. health는 transport/http, pages0, browserRequiredfalse 및 상태를 표시한다. 실행 Python 경로는 OY_HTTP_PYTHON으로 지정한다. + +검증: Python helper의 allowlist/redirect/status/envelope/size/error를 오프라인 fixture로 테스트하고 Node 실행·상태·종료를 검증. 실환경 네 API3회 및 상품2종의 실제 매장재고를 기존 브라우저와 대조. 전체 check/coverage100/build/audit 통과 후 PR 병합, 맥 runtime 백업·원장 보존·종료 확인 후 http 모드 전환. REST/MCP/게시CLI/health 및 소유 브라우저 없음 확인. 장기 차단 없음은 보장하지 않는다. diff --git a/scripts/relay/http-lifecycle.ts b/scripts/relay/http-lifecycle.ts new file mode 100644 index 00000000..24e72981 --- /dev/null +++ b/scripts/relay/http-lifecycle.ts @@ -0,0 +1,103 @@ +/** 고정 Python 헬퍼의 시작·요청·종료를 브라우저 없이 관리합니다. */ +import { execFile, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import type { BrowserRunner } from './oliveyoung.js'; +const LIMIT = 2 * 1024 * 1024; +export function createHttpLifecycle() { + const python = process.env.OY_HTTP_PYTHON || 'python3'; + const helper = fileURLToPath(new URL('./oliveyoung-http.py', import.meta.url)); + // 토큰·프록시·Python 설정은 전달하지 않고 실행 파일 탐색 경로만 허용합니다. + const env = { PATH: process.env.PATH || '/usr/bin:/bin' }; + let state: 'starting' | 'ready' | 'closed' = 'starting'; + const isClosed = () => state === 'closed'; + let child: ChildProcess | undefined; + let pending: Promise | undefined; + let starting: Promise | undefined; + let active = false; + let calls = 0; + const execute = (check: boolean, input?: string) => { + pending = new Promise((resolve, reject) => { + child = execFile( + python, + ['-I', helper, ...(check ? ['--check'] : [])], + { + env, + timeout: 18000, + killSignal: 'SIGKILL', + maxBuffer: LIMIT, + encoding: 'utf8', + }, + (error, stdout) => { + child = undefined; + if (error || isClosed()) reject(new Error('HTTP relay request failed')); + else resolve(stdout); + }, + ); + // 출력 파이프 종료는 콜백에서 처리하고 입력 오류는 비밀 없이 보고합니다. + child.stdin!.on('error', () => child?.kill('SIGKILL')); + child.stdin!.end(input); + }); + return pending; + }; + const start = async () => { + if (isClosed()) throw new Error('HTTP lifecycle closed'); + if (state === 'ready') return; + starting ??= (async () => { + const output = await execute(true); + try { + if (JSON.parse(output)?.ready !== true) throw new Error(); + } catch { + throw new Error('HTTP relay request failed'); + } + if (isClosed()) throw new Error('HTTP lifecycle closed'); + state = 'ready'; + })(); + await starting; + }; + const run: BrowserRunner = async (path, body) => { + if (isClosed()) throw new Error('HTTP lifecycle closed'); + if (state !== 'ready') throw new Error('HTTP lifecycle not ready'); + if (active) throw new Error('HTTP lifecycle already active'); + active = true; + calls++; + try { + const input = JSON.stringify({ path, body }); + if (Buffer.byteLength(input) > LIMIT) throw new Error(); + const result = JSON.parse(await execute(false, input)); + if ( + !result || + result.status !== 'SUCCESS' || + !result.data || + typeof result.data !== 'object' || + Array.isArray(result.data) + ) + throw new Error(); + return result; + } catch { + throw new Error('HTTP relay request failed'); + } finally { + active = false; + } + }; + return { + start, + run, + async close() { + state = 'closed'; + child?.kill('SIGKILL'); + await pending?.catch(() => undefined); + }, + status: () => ({ + state, + transport: 'http', + browserRequired: false, + pages: 0, + active, + calls, + totalCalls: calls, + rotations: 0, + rssBytes: 0, + reason: state === 'closed' ? 'shutdown' : 'http', + }), + }; +} diff --git a/scripts/relay/oliveyoung-http.py b/scripts/relay/oliveyoung-http.py new file mode 100644 index 00000000..2ad68fa9 --- /dev/null +++ b/scripts/relay/oliveyoung-http.py @@ -0,0 +1,78 @@ +"""쿠키와 리다이렉트 없이 네 개의 공식 조회 경로만 호출합니다.""" +import json +import sys +import urllib.request + +ORIGIN = 'https://www.oliveyoung.co.kr' +PATHS = frozenset([ + '/oystore/api/storeFinder/find-store', + '/oystore/api/stock/product-search-v3', + '/oystore/api/stock/stock-goods-info-v3', + '/oystore/api/stock/stock-stores', +]) +LIMIT = 2 * 1024 * 1024 +HEADERS = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36', + 'Origin': ORIGIN, + 'Referer': ORIGIN + '/', + 'Accept-Language': 'ko-KR,ko;q=0.9', +} + +class RelayError(Exception): + """상위 응답 본문을 노출하지 않는 고정 오류입니다.""" + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + +def fetch(path, body): + if not isinstance(path, str) or path not in PATHS or not isinstance(body, dict): + raise RelayError('Invalid request') + try: + payload = json.dumps(body, ensure_ascii=False, allow_nan=False).encode('utf-8') + if len(payload) > LIMIT: + raise RelayError('Invalid request') + request = urllib.request.Request(ORIGIN + path, data=payload, headers=HEADERS, method='POST') + # 환경 프록시와 리다이렉트를 모두 차단합니다. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + with opener.open(request, timeout=15) as response: + if response.status != 200: + raise RelayError('Upstream unavailable') + raw = response.read(LIMIT + 1) + if len(raw) > LIMIT: + raise RelayError('Upstream unavailable') + result = json.loads(raw) + if not isinstance(result, dict) or result.get('status') != 'SUCCESS' or not isinstance(result.get('data'), dict): + raise RelayError('Upstream unavailable') + return result + except Exception: + raise RelayError('Upstream unavailable') from None + +def main(): + if sys.argv[1:] == ['--check']: + print('{"ready":true}') + return 0 + try: + if sys.argv[1:]: + raise RelayError('Invalid request') + raw = sys.stdin.buffer.read(LIMIT + 1) + if len(raw) > LIMIT: + raise RelayError('Invalid request') + request = json.loads(raw) + if not isinstance(request, dict) or set(request) != {'path', 'body'}: + raise RelayError('Invalid request') + result = fetch(request['path'], request['body']) + output = json.dumps(result, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode('utf-8') + if len(output) > LIMIT: + raise RelayError('Upstream unavailable') + sys.stdout.buffer.write(output) + return 0 + except Exception: + print('HTTP relay request failed', file=sys.stderr) + return 1 + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/relay/start.ts b/scripts/relay/start.ts index 637a6d13..3eaf08fc 100644 --- a/scripts/relay/start.ts +++ b/scripts/relay/start.ts @@ -6,8 +6,7 @@ import { Readable } from 'node:stream'; import { pathToFileURL } from 'node:url'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; -import { createBrowserLifecycle } from './lifecycle.js'; -import { launchGuardedBrowser } from './supervisor.js'; +import { createTransportLifecycle } from './transport-lifecycle.js'; import { createFileQuota } from './quota.js'; import { createOliveyoungRelay } from './oliveyoung.js'; @@ -31,9 +30,15 @@ async function main() { 60000, ); heartbeat.unref(); - const lifecycle = createBrowserLifecycle( - () => launchGuardedBrowser(join(stateDir, 'browser-owner.json')), - (outcome) => log.append({ stage: 'lifecycle', operation: 'browser', outcome }), + const lifecycle = await createTransportLifecycle( + process.env.OY_RELAY_TRANSPORT, + stateDir, + (outcome) => + log.append({ + stage: 'lifecycle', + operation: process.env.OY_RELAY_TRANSPORT || 'browser', + outcome, + }), ); try { await lifecycle.start(); @@ -101,7 +106,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main().catch(async () => { logger?.append({ stage: 'process', outcome: 'startup-failed' }); await logger?.flush(); - console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 GUI 브라우저 접속을 확인하세요.'); + console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 전송 환경을 확인하세요.'); process.exitCode = 1; }); } diff --git a/scripts/relay/transport-lifecycle.ts b/scripts/relay/transport-lifecycle.ts new file mode 100644 index 00000000..a6cb7746 --- /dev/null +++ b/scripts/relay/transport-lifecycle.ts @@ -0,0 +1,20 @@ +/** HTTP 경로에서는 Playwright 모듈 자체를 불러오지 않습니다. */ +import { join } from 'node:path'; +export async function createTransportLifecycle( + mode: string | undefined, + stateDir: string, + onEvent?: (reason: string) => void | Promise, +) { + if (mode === 'http') { + const { createHttpLifecycle } = await import('./http-lifecycle.js'); + return createHttpLifecycle(); + } + if (mode !== undefined && mode !== 'browser') + throw new Error('OY_RELAY_TRANSPORT 값이 잘못되었습니다.'); + const { createBrowserLifecycle } = await import('./lifecycle.js'); + const { launchGuardedBrowser } = await import('./supervisor.js'); + return createBrowserLifecycle( + () => launchGuardedBrowser(join(stateDir, 'browser-owner.json')), + onEvent, + ); +} diff --git a/tests/relay/http-lifecycle.test.ts b/tests/relay/http-lifecycle.test.ts new file mode 100644 index 00000000..ccd46064 --- /dev/null +++ b/tests/relay/http-lifecycle.test.ts @@ -0,0 +1,183 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +const exec = vi.hoisted(() => vi.fn()); +vi.mock('node:child_process', () => ({ execFile: exec })); +import { createHttpLifecycle } from '../../scripts/relay/http-lifecycle.js'; +let child: EventEmitter & { stdin: PassThrough; kill: ReturnType }; +let done: (error: Error | null, stdout: string) => void; +beforeEach(() => { + exec.mockReset(); + exec.mockImplementation((_command, _args, _options, callback) => { + done = callback; + child = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), + kill: vi.fn(() => { + callback(new Error('secret'), ''); + return true; + }), + }); + return child; + }); +}); +afterEach(() => vi.unstubAllEnvs()); +async function ready() { + const lifecycle = createHttpLifecycle(); + const start = lifecycle.start(); + done(null, '{"ready":true}'); + await start; + return lifecycle; +} +it('checks Python in isolated mode with an allowlisted environment and no browser', async () => { + vi.stubEnv('OY_RELAY_TOKEN', 'secret'); + vi.stubEnv('PYTHONPATH', 'secret'); + vi.stubEnv('OY_HTTP_PYTHON', '/usr/bin/python3'); + const lifecycle = await ready(); + expect(exec.mock.calls[0][0]).toBe('/usr/bin/python3'); + expect(exec.mock.calls[0][1]).toEqual([ + '-I', + expect.stringContaining('oliveyoung-http.py'), + '--check', + ]); + expect(exec.mock.calls[0][2]).toMatchObject({ timeout: 18000, maxBuffer: 2 * 1024 * 1024 }); + expect(exec.mock.calls[0][2].env).not.toHaveProperty('OY_RELAY_TOKEN'); + expect(exec.mock.calls[0][2].env).not.toHaveProperty('PYTHONPATH'); + expect(lifecycle.status()).toMatchObject({ + state: 'ready', + transport: 'http', + browserRequired: false, + pages: 0, + calls: 0, + }); + await lifecycle.start(); + expect(exec).toHaveBeenCalledTimes(1); + await lifecycle.close(); +}); +it('sends only path/body over stdin and counts successful calls', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('/oystore/api/stock/stock-stores', { productId: 'x' }); + expect(lifecycle.status().active).toBe(true); + expect(child.stdin.read().toString()).toBe( + JSON.stringify({ path: '/oystore/api/stock/stock-stores', body: { productId: 'x' } }), + ); + await expect(lifecycle.run('x', {})).rejects.toThrow('active'); + done(null, '{"status":"SUCCESS","data":{}}'); + await expect(call).resolves.toEqual({ status: 'SUCCESS', data: {} }); + expect(lifecycle.status()).toMatchObject({ active: false, calls: 1, totalCalls: 1 }); + await lifecycle.close(); +}); +it.each([ + 'bad JSON', + '{"status":"ERROR","data":{}}', + '{"status":"SUCCESS","data":[]}', + '{"status":"SUCCESS"}', + 'null', +])('rejects malformed helper output: %s', async (output) => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + done(null, output); + await expect(call).rejects.toThrow('HTTP relay request failed'); + expect(lifecycle.status()).toMatchObject({ active: false, calls: 1, totalCalls: 1 }); + await lifecycle.close(); +}); +it('hides child failures, including timeout and response overflow', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + done(new Error('secret body'), 'secret body'); + await expect(call).rejects.toThrow(/^HTTP relay request failed$/); + await lifecycle.close(); +}); +it.each([null, new Error('missing python')])( + 'rejects startup failures without leaking output', + async (error) => { + const lifecycle = createHttpLifecycle(); + expect(lifecycle.status().state).toBe('starting'); + const start = lifecycle.start(); + done(error, 'secret'); + await expect(start).rejects.toThrow(/^HTTP relay request failed$/); + await lifecycle.close(); + }, +); +it('kills an in-flight child and prevents later calls or starts', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + const rejection = expect(call).rejects.toThrow(); + await lifecycle.close(); + await rejection; + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + expect(lifecycle.status().state).toBe('closed'); + await expect(lifecycle.run('x', {})).rejects.toThrow('closed'); + await expect(lifecycle.start()).rejects.toThrow('closed'); + await lifecycle.close(); +}); +it('rejects requests before startup', async () => { + const lifecycle = createHttpLifecycle(); + await expect(lifecycle.run('x', {})).rejects.toThrow('ready'); + await lifecycle.close(); +}); +it('shares a pending startup and can close while starting', async () => { + const lifecycle = createHttpLifecycle(); + const start = lifecycle.start(); + const another = lifecycle.start(); + const caught = Promise.allSettled([start, another]); + expect(exec).toHaveBeenCalledTimes(1); + await lifecycle.close(); + expect((await caught).every((result) => result.status === 'rejected')).toBe(true); +}); +it('does not become ready when closed immediately after startup output', async () => { + const lifecycle = createHttpLifecycle(); + const start = lifecycle.start(); + const rejection = expect(start).rejects.toThrow('closed'); + done(null, '{"ready":true}'); + await lifecycle.close(); + await rejection; + expect(lifecycle.status().state).toBe('closed'); +}); +it('bounds serialized input before launching a request', async () => { + const lifecycle = await ready(); + await expect(lifecycle.run('x', { value: 'x'.repeat(2 * 1024 * 1024) })).rejects.toThrow( + 'HTTP relay request failed', + ); + expect(exec).toHaveBeenCalledTimes(1); + await lifecycle.close(); +}); +it('kills the helper on a broken input pipe', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + const rejection = expect(call).rejects.toThrow('HTTP relay request failed'); + child.stdin.emit('error', new Error('private')); + await rejection; + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + await lifecycle.close(); +}); +it('sanitizes serialization and synchronous spawn failures', async () => { + const lifecycle = await ready(); + await expect(lifecycle.run('x', { value: 1n })).rejects.toThrow('HTTP relay request failed'); + exec.mockImplementationOnce(() => { + throw new Error('private path'); + }); + await expect(lifecycle.run('x', {})).rejects.toThrow('HTTP relay request failed'); + await lifecycle.close(); +}); +it('uses a system executable search path when PATH is absent', async () => { + vi.stubEnv('PATH', ''); + const lifecycle = await ready(); + expect(exec.mock.calls[0][2].env).toEqual({ PATH: '/usr/bin:/bin' }); + await lifecycle.close(); +}); +it('rejects a null startup envelope', async () => { + const lifecycle = createHttpLifecycle(); + const start = lifecycle.start(); + done(null, 'null'); + await expect(start).rejects.toThrow('HTTP relay request failed'); + await lifecycle.close(); +}); +it('ignores input pipe errors after the child has already exited', async () => { + const lifecycle = await ready(); + const call = lifecycle.run('x', {}); + const stdin = child.stdin; + done(null, '{"status":"SUCCESS","data":{}}'); + stdin.emit('error', new Error('private')); + await call; + await lifecycle.close(); +}); diff --git a/tests/relay/oliveyoung-http-python.test.ts b/tests/relay/oliveyoung-http-python.test.ts new file mode 100644 index 00000000..b0129811 --- /dev/null +++ b/tests/relay/oliveyoung-http-python.test.ts @@ -0,0 +1,33 @@ +/** Python 기본 라이브러리 헬퍼의 오프라인 검증도 일반 CI 테스트에 포함합니다. */ +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { expect, it } from 'vitest'; +it('passes the offline Python HTTP security and response tests', () => { + const script = fileURLToPath(new URL('./test_oliveyoung_http.py', import.meta.url)); + const result = execFileSync('python3', ['-I', script], { + encoding: 'utf8', + timeout: 10000, + stdio: 'pipe', + }); + expect(result).toBe(''); +}); +import { vi } from 'vitest'; +vi.mock('../../scripts/relay/lifecycle.js', () => { + throw new Error('HTTP imported browser lifecycle'); +}); +vi.mock('../../scripts/relay/supervisor.js', () => { + throw new Error('HTTP imported Playwright launcher'); +}); +import { createTransportLifecycle } from '../../scripts/relay/transport-lifecycle.js'; +it('starts the real helper and rejects an invalid path without loading browser modules', async () => { + const lifecycle = await createTransportLifecycle('http', '/unused'); + try { + await lifecycle.start(); + expect(lifecycle.status()).toMatchObject({ state: 'ready', browserRequired: false }); + await expect(lifecycle.run('https://evil.test', {})).rejects.toThrow( + /^HTTP relay request failed$/, + ); + } finally { + await lifecycle.close(); + } +}); diff --git a/tests/relay/test_oliveyoung_http.py b/tests/relay/test_oliveyoung_http.py new file mode 100644 index 00000000..4ac9afa7 --- /dev/null +++ b/tests/relay/test_oliveyoung_http.py @@ -0,0 +1,77 @@ +"""외부 네트워크 없이 고정 HTTP 헬퍼 계약을 검증합니다.""" +import importlib.util +import io +import json +from pathlib import Path +import unittest +from unittest.mock import patch +from urllib.error import HTTPError + +SCRIPT = Path(__file__).resolve().parents[2] / 'scripts/relay/oliveyoung-http.py' + +class HelperTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + spec = importlib.util.spec_from_file_location('oy_http', SCRIPT) + cls.helper = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.helper) + + def request(self, raw=b'{"status":"SUCCESS","data":{}}', status=200): + response = io.BytesIO(raw) + response.status = status + opener = unittest.mock.Mock() + opener.open.return_value = response + return opener + + def test_success_and_fixed_headers(self): + for path in self.helper.PATHS: + opener = self.request() + with patch.object(self.helper.urllib.request, 'build_opener', return_value=opener): + self.assertEqual(self.helper.fetch(path, {}), {'status': 'SUCCESS', 'data': {}}) + request = opener.open.call_args.args[0] + self.assertEqual(request.full_url, 'https://www.oliveyoung.co.kr' + path) + self.assertEqual(request.method, 'POST') + self.assertEqual(opener.open.call_args.kwargs['timeout'], 15) + self.assertNotIn('Cookie', request.headers) + self.assertEqual(request.get_header('Origin'), 'https://www.oliveyoung.co.kr') + self.assertEqual(request.get_header('X-requested-with'), 'XMLHttpRequest') + + def test_failures(self): + cases = [(b'private body', 403), (b'private body', 302), (b'bad', 200), + (b'[]', 200), (b'{"status":"ERROR","data":{}}', 200), + (b'{"status":"SUCCESS","data":[]}', 200), + (b'{"status":"SUCCESS"}', 200), (b'x' * (self.helper.LIMIT + 1), 200)] + for raw, status in cases: + with self.subTest(status=status, size=len(raw)): + with patch.object(self.helper.urllib.request, 'build_opener', return_value=self.request(raw, status)): + with self.assertRaises(self.helper.RelayError): + self.helper.fetch(next(iter(self.helper.PATHS)), {}) + + def test_invalid_path_body_and_no_redirects(self): + with patch.object(self.helper.urllib.request, 'build_opener') as build: + for path in ['https://evil.test', '/oystore/api/stock/stock-stores?x=1', '//evil.test']: + with self.assertRaises(self.helper.RelayError): self.helper.fetch(path, {}) + with self.assertRaises(self.helper.RelayError): self.helper.fetch(next(iter(self.helper.PATHS)), []) + build.assert_not_called() + handler = self.helper.NoRedirect() + self.assertIsNone(handler.redirect_request(None, None, 302, '', {}, 'https://www.oliveyoung.co.kr/')) + + def test_http_error_and_timeout_are_sanitized(self): + for error in [HTTPError('secret', 403, 'private', {}, io.BytesIO(b'secret')), TimeoutError('private')]: + opener = self.request() + opener.open.side_effect = error + with patch.object(self.helper.urllib.request, 'build_opener', return_value=opener): + with self.assertRaisesRegex(self.helper.RelayError, '^Upstream unavailable$'): + self.helper.fetch(next(iter(self.helper.PATHS)), {}) + + def test_main_check_invalid_and_large_input(self): + with patch.object(self.helper.sys, 'argv', ['helper', '--check']), patch.object(self.helper.sys, 'stdout', new=io.StringIO()) as output: + self.assertEqual(self.helper.main(), 0) + self.assertEqual(json.loads(output.getvalue()), {'ready': True}) + for data in [b'x', b'[]', b'{}', b'x' * (self.helper.LIMIT + 1)]: + stdin = unittest.mock.Mock(buffer=io.BytesIO(data)) + with patch.object(self.helper.sys, 'argv', ['helper']), patch.object(self.helper.sys, 'stdin', stdin), patch.object(self.helper.sys, 'stderr', new=io.StringIO()) as output: + self.assertEqual(self.helper.main(), 1) + self.assertEqual(output.getvalue(), 'HTTP relay request failed\n') + +if __name__ == '__main__': unittest.main() diff --git a/tests/relay/transport-lifecycle.test.ts b/tests/relay/transport-lifecycle.test.ts new file mode 100644 index 00000000..91ae3129 --- /dev/null +++ b/tests/relay/transport-lifecycle.test.ts @@ -0,0 +1,29 @@ +import { expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ + browser: vi.fn(() => 'browser'), + http: vi.fn(() => 'http'), + launch: vi.fn(), +})); +vi.mock('../../scripts/relay/lifecycle.js', () => ({ createBrowserLifecycle: mocks.browser })); +vi.mock('../../scripts/relay/http-lifecycle.js', () => ({ createHttpLifecycle: mocks.http })); +vi.mock('../../scripts/relay/supervisor.js', () => ({ launchGuardedBrowser: mocks.launch })); +import { createTransportLifecycle } from '../../scripts/relay/transport-lifecycle.js'; +it('defaults to the existing browser lifecycle and forwards observer/ownership path', async () => { + const observer = vi.fn(); + expect(await createTransportLifecycle(undefined, '/state', observer)).toBe('browser'); + const [launch, observed] = mocks.browser.mock.calls[0] as unknown as [() => unknown, unknown]; + launch(); + expect(mocks.launch).toHaveBeenCalledWith('/state/browser-owner.json'); + expect(observed).toBe(observer); +}); +it('selects HTTP without importing or launching browser modules', async () => { + mocks.browser.mockClear(); + mocks.launch.mockClear(); + expect(await createTransportLifecycle('http', '/state')).toBe('http'); + expect(mocks.browser).not.toHaveBeenCalled(); + expect(mocks.launch).not.toHaveBeenCalled(); +}); +it('accepts explicit browser and rejects unknown transport', async () => { + expect(await createTransportLifecycle('browser', '/state')).toBe('browser'); + await expect(createTransportLifecycle('bogus', '/state')).rejects.toThrow('OY_RELAY_TRANSPORT'); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 57fc1203..28ee5d34 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/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'], + 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', 'scripts/relay/http-lifecycle.ts', 'scripts/relay/transport-lifecycle.ts'], // 커버리지 제외 파일 exclude: [