diff --git a/.env.example b/.env.example index 1557448c..994b8c5a 100644 --- a/.env.example +++ b/.env.example @@ -16,3 +16,9 @@ OY_RELAY_TOKEN= # 로컬 릴레이 실행용 선택 설정 OY_RELAY_PORT=4319 OY_BROWSER_EXECUTABLE= + +# 선택: Cloudflare Access로 보호한 올리브영 중계의 서비스 인증 +OY_ACCESS_CLIENT_ID= +OY_ACCESS_CLIENT_SECRET= +# 로컬 중계 전용 상태 디렉터리. 호출 원장과 브라우저 소유 마커를 보관합니다. +# OY_RELAY_STATE_DIR=/Users/daisorelay/Library/Application Support/DaisoRelay/state diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index f3e60461..5d1f47e7 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -30,12 +30,12 @@ Cloudflare Worker에서 쓰려면 승인된 별도 연결 경로가 필요합니 `POST /v1/oliveyoung/{operation}`에 JSON 본문과 `Authorization: Bearer …`를 보냅니다. 대상 호스트는 `https://www.oliveyoung.co.kr`로 고정하며 임의 URL은 받지 않습니다. -| operation | JSON 필드 | -| --- | --- | -| `find-store` | `lat`, `lon`, `pageIdx`, `searchWords`, `pogKeys`, `serviceKeys`, `mapLat`, `mapLon` | -| `product-search-v3` | `includeSoldOut`, `keyword`, `page`, `sort`, `size` | -| `stock-goods-info-v3` | `goodsNo` | -| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` | +| operation | JSON 필드 | +| --------------------- | ------------------------------------------------------------------------------------ | +| `find-store` | `lat`, `lon`, `pageIdx`, `searchWords`, `pogKeys`, `serviceKeys`, `mapLat`, `mapLon` | +| `product-search-v3` | `includeSoldOut`, `keyword`, `page`, `sort`, `size` | +| `stock-goods-info-v3` | `goodsNo` | +| `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` | 숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 15초 이상 대기한 요청은 실행하지 않습니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다. @@ -50,3 +50,46 @@ Cloudflare Worker에서 쓰려면 승인된 별도 연결 경로가 필요합니 - 테스트: `npx vitest run tests/relay tests/services/oliveyoung tests/api/oliveyoung-handlers.test.ts tests/app/app-api-oliveyoung.test.ts tests/app/app-api-actions.test.ts`. 공개 Worker에서 이 로컬 Mac으로 연결되는 경로는 이번 smoke로 검증한 범위에 포함되지 않습니다. + +## 상시 운영 보강 + +중계는 한 브라우저·한 컨텍스트·한 페이지를 재사용합니다. 요청마다 탭을 만들지 않습니다. 추가 페이지는 즉시 회수하며 회수 실패·페이지 crash·Node 측 watchdog timeout에는 소유한 브라우저 세션을 폐기합니다. 200회 조회 또는 30분 사용 후 유휴 경계에서 정리하고 다음 요청에 새 세션을 만듭니다. + +브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. + +소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. + +### 전체 호출량과 인증 + +모든 중계 호출은 전체 분당 30회, UTC 하루 3,000회로 제한합니다. MCP·REST 모두 같은 중계를 거치므로 호출 경로로 우회할 수 없습니다. 원장을 `OY_RELAY_STATE_DIR`에 원자적으로 저장하며 저장 실패·손상은 조회 거절로 처리합니다. 본문 업로드 중인 요청도 최대 8개 슬롯에 포함합니다. 실패한 원본 요청도 이미 소비한 예산을 돌려주지 않습니다. + +Cloudflare Access 사용 시 Worker secrets에 `OY_ACCESS_CLIENT_ID`와 `OY_ACCESS_CLIENT_SECRET`을 함께 설정합니다. 기존 `OY_RELAY_TOKEN`과 별개입니다. 사용자 도구 입력으로 전달하거나 덮어쓸 수 없습니다. Access 비밀이 다른 주소로 전달되지 않도록 리다이렉트를 거절합니다. Access 정책은 해당 서비스 토큰만 허용하는 Service Auth로 구성하며, Tunnel은 localhost:4319와 최종 404 경로만 연결합니다. 브라우저 디버깅 주소와 개인 네트워크 경로는 연결하지 않습니다. + +### macOS 전용 계정 설치 + +시스템 설정에서 일반 사용자 계정 `daisorelay`를 만듭니다. 관리자 계정은 설치 스크립트가 거절합니다. 코드와 전용 브라우저는 `/Library/Application Support/DaisoRelay`에 root 소유로 설치하고, 토큰과 상태는 전용 계정 홈에만 둡니다. 자동 로그인은 설정하지 않습니다. 계정 생성과 최초 GUI 로그인은 사용자가 직접 수행해야 합니다. + +설치 도구는 기본적으로 변경 없는 계획만 출력합니다. `--apply`는 검토 후 사용자의 관리자 터미널에서 실행합니다. Cloudflare 자격증명 파일은 별도로 준비된 mode 0600 파일을 지정하며 채팅·Git에 복사하지 않습니다. + +```sh +/usr/bin/python3 scripts/relay/install-macos.py \ + --browser-app '/absolute/path/Google Chrome for Testing.app' \ + --credentials '/absolute/private/path/cf-private.json' +``` + +실제 설치는 같은 명령 앞에 `sudo`, 끝에 `--apply`를 붙입니다. root 권한으로 npm 설치나 외부 코드를 다운로드하지 않으며, 사전에 검증한 코드·의존성·브라우저를 복사합니다. 기존 설치가 있으면 자동 덮어쓰지 않고 중단합니다. + +두 LaunchAgent는 `daisorelay`의 GUI 로그인 시 시작합니다. 브라우저의 GUI 세션이 필요하므로 부팅만 완료된 로그인 화면에서는 조회를 제공하지 않습니다. 실패 재시작은 최소 60초 간격이며, 중계의 정상 종료는 자동 재시작하지 않습니다. stdout/stderr은 `/dev/null`로 보내므로 디스크 로그가 누적되지 않습니다. 진단은 인증된 health와 launchctl 종료 상태로 확인합니다. + +전용 계정의 터미널에서 아래 명령으로 두 작업을 내릴 수 있습니다. + +```sh +launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung-tunnel" +launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung" +``` + +업그레이드는 두 작업을 중지하고 소유 브라우저가 없는 것을 확인한 뒤 수행해야 합니다. 기존 원장은 보존합니다. 전용 계정도 시스템에서 읽기 허용한 파일에는 접근할 수 있으므로, 개인 비밀은 기존 파일 권한으로 보호해야 합니다. + +### 2026-09-15 운영 보강 검증 + +Playwright Chromium 153.0.8010.12에서 sandbox를 켜고 검증했습니다. 실제 조회 두 건과 부모 SIGKILL 후 잔여 소유 프로세스 0개를 확인했습니다. 모의 브라우저 API 조회 400회로 두 번 교체하고 각 교체 후에도 잔여 프로세스 0개를 확인했습니다. 팝업, 실제 renderer crash, 18초 Node watchdog도 검증했습니다. 메모리는 시점별 약 519~632MiB로 관측됐으며 장기간 무누수나 최고 사용량 보장을 의미하지 않습니다. diff --git a/docs/release-1.2.1.md b/docs/release-1.2.1.md new file mode 100644 index 00000000..df899cdb --- /dev/null +++ b/docs/release-1.2.1.md @@ -0,0 +1,13 @@ +# 1.2.1 릴리스 노트 + +## 올리브영 중계 운영 + +중계 브라우저의 탭 재사용, 사용량·시간에 따른 세션 정리, RSS 감시와 요청 watchdog을 추가했습니다. 별도 guard가 브라우저를 소유하며 부모 프로세스의 강제 종료에도 회수를 담당합니다. 소유한 프로세스의 종료를 확인하지 못하면 새 브라우저를 만들지 않습니다. + +모든 중계 조회에 재시작 후에도 유지되는 분당 30회·UTC 일일 3,000회 상한을 적용했습니다. 본문 읽기 중인 요청도 8개 슬롯 상한에 포함하며 브라우저 응답은 2MiB로 제한합니다. + +Cloudflare Access 서비스 인증을 Worker 운영 바인딩에서 전달합니다. 리다이렉트를 거절해 인증 정보가 다른 서버로 전달되지 않게 했습니다. 중계용 전용 표준 macOS 계정 설치 도구와 운영 절차를 추가했습니다. + +## 적용 범위 + +Zyte 사용 중지 정책은 유지합니다. 중계 코드의 배포만으로 Mac과 Cloudflare가 연결되지는 않습니다. 운영자는 전용 GUI 계정, Access 정책, Tunnel, DNS와 Worker secrets를 구성하고 실제 조회를 검증해야 합니다. 설치 도구의 기본 실행은 변경 없는 계획 출력입니다. diff --git a/docs/superpowers/plans/2026-09-15-relay-operations.md b/docs/superpowers/plans/2026-09-15-relay-operations.md new file mode 100644 index 00000000..8e81a4d1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-relay-operations.md @@ -0,0 +1,40 @@ +# Mac 중계 운영 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox syntax for tracking. + +**Goal:** 승인된 Mac 중계를 제한된 자원과 인증 경계 안에서 운영합니다. +**Architecture:** Worker → Access → Tunnel → localhost 중계 → 소유한 전용 Chromium. 계정·프로세스·입출력·요청량을 제한합니다. +**Tech Stack:** Node.js, Playwright, Vitest, macOS launchd, Cloudflare APIs. + +## Task 1: 브라우저 수명과 서버 종료 + +Files: scripts/relay/start.ts, browser.ts, 새 lifecycle/소유 프로세스 모듈, tests/relay 대응 테스트. + +- [x] 기존 baseline 테스트를 확인합니다. +- [x] RED: 반복 요청에 `newPage` 한 번, 교체 전 `close` 완료, close 지연·실패에 새 launch 0회, 팝업 회수·crash·timeout·shutdown 테스트를 작성하고 실패를 확인합니다. +- [x] GREEN: 단일 소유 세션과 Node watchdog, 30분/200회 교체, RSS 1GiB 점검, bounded graceful/force cleanup을 구현합니다. 소유 여부 확인 없는 PID 종료는 금지합니다. +- [x] 본문 2MiB 상한, health 상태와 종료/리스너 정리를 검증합니다. 수명 모듈을 coverage 대상에 포함합니다. +- [x] 사양 리뷰 후 품질 리뷰를 수행합니다. + +## Task 2: 인증 전달과 호출량 제한 + +Files: src/services/oliveyoung/transport.ts, src/api/response.ts, REST/MCP 옵션 전달부, scripts/relay/quota.ts, oliveyoung.ts, tests. + +- [x] RED: Access 두 헤더 전달·부분 설정 거절·인자 override 금지, 30/분·3000/일 경계와 재시작 보존·저장 실패 테스트를 작성합니다. +- [x] GREEN: Worker secret 바인딩에서만 Access 헤더를 전달합니다. 파일 기반 원자적 quota와 본문 읽기 전 큐 슬롯 확보를 구현합니다. +- [x] 사양 리뷰 후 품질 리뷰를 수행합니다. + +## Task 3: 설치와 운영 연결 + +Files: scripts/relay 설치/운영 도구, docs/oliveyoung-free-relay.md, 개인 scratch의 비밀 파일. + +- [ ] 전용 표준 계정에 검토 가능한 설치 스크립트를 준비합니다. 관리자 인증과 GUI 로그인은 사용자에게 필요한 시점에 요청합니다. +- [ ] 고정 Tunnel/DNS와 Access Service Auth 정책을 구성합니다. 생성 식별자와 복구 절차를 기록합니다. 비밀을 stdout에 출력하지 않습니다. +- [ ] launchd singleton·재시작 간격·로그 크기 관리·소유 프로세스 회수 검사를 설치하고 로컬 반복 smoke를 수행합니다. +- [ ] Worker secrets 연결 후 Access 거절·인증 통과·실제 MCP 조회를 확인합니다. + +## Task 4: 통합과 기록 + +- [ ] `npm run check`, `npm run test:coverage` 100%, `npm run build`, `npm audit`를 확인합니다. +- [ ] 리뷰된 커밋을 PR로 올리고 CI·Coverage·CodeQL을 확인한 뒤 승인 범위 안에서 병합·배포합니다. +- [ ] 배포 결과, RSS·탭·프로세스 회수 실측과 미완료 조건을 개인 PROJECT.md에 기록합니다. diff --git a/docs/superpowers/specs/2026-09-15-relay-operations-design.md b/docs/superpowers/specs/2026-09-15-relay-operations-design.md new file mode 100644 index 00000000..0343769d --- /dev/null +++ b/docs/superpowers/specs/2026-09-15-relay-operations-design.md @@ -0,0 +1,23 @@ +# 올리브영 Mac 중계 운영 설계 + +사용자는 전용 계정·브라우저·조회 서버·Cloudflare Tunnel·Access 구성을 승인했으며 메모리와 탭 회수 실패 방지를 추가로 요구했습니다. + +## 실행 경계 + +기존 Worker는 Access 서비스 토큰과 중계 Bearer 토큰으로 고정된 중계 주소를 호출합니다. localhost:4319만 Tunnel에 연결합니다. 임의 URL, 브라우저 디버깅 포트, SSH, 개인 파일은 터널 대상에 넣지 않습니다. 중계는 전용 표준 macOS 사용자로 실행합니다. 계정 생성·GUI 로그인에 필요한 관리자 인증은 사용자가 직접 수행하며 비밀번호를 요청하거나 기록하지 않습니다. + +## 자원 수명 + +브라우저 1개·컨텍스트 1개·기본 페이지 1개를 유지합니다. 요청별 새 탭을 만들지 않습니다. 예기치 않은 팝업은 닫고 닫기 실패 시 세션을 폐기합니다. 브라우저 종료를 확인하기 전에는 후속 브라우저를 실행하지 않습니다. launch 실패·요청 timeout·페이지 crash·SIGTERM·재시작에도 소유한 프로세스만 회수합니다. 개인 브라우저를 이름으로 일괄 종료하지 않습니다. + +세션은 최대 30분 또는 200회 조회 후 유휴 경계에서 교체합니다. 15초 요청 제한에 더해 Node 측 watchdog으로 브라우저 이벤트 루프 정지도 감지합니다. 종료 timeout에는 소유 프로세스에만 강제 종료를 적용하고 확인이 불가능하면 fail-closed로 중지합니다. 30초 주기의 전체 소유 프로세스 RSS 점검으로 1GiB 초과 시 회수합니다. Node에도 256MiB heap 상한을 둡니다. 이 값은 운영 초깃값이며 무누수를 보증하는 수치가 아닙니다. + +## 입력·부하·비밀 + +기존 고정 네 API·필드 타입·16KiB 검증을 유지합니다. 실행·대기 합계 8개, 직렬 실행을 유지합니다. 중계 전체에 분당 30회·UTC 하루 3,000회 상한을 적용하고 파일에 원자적으로 저장해 재시작으로 초기화되지 않게 합니다. 저장 실패·손상은 요청 거절로 처리합니다. 큐 슬롯은 본문 읽기 전 확보하여 느린 업로드도 제한합니다. 원본 JSON 응답은 최대 2MiB로 제한합니다. + +토큰과 사용자 검색어·좌표·원본 응답은 로그에 남기지 않습니다. 운영 로그는 상태·건수·RSS·탭 수·재시작 사유만 기록하며 크기를 제한합니다. 토큰은 Worker secrets와 전용 계정 읽기 권한의 파일에 보관합니다. + +## 배포·검증 + +새 기능은 TDD·100% coverage·check·build·audit와 독립 리뷰를 거칩니다. 실제 브라우저에서 반복 조회, 강제 팝업, timeout, 순차 교체, SIGTERM 및 재기동을 검증하고 브라우저/탭/프로세스 수와 RSS를 기록합니다. Access 무인증 차단, 잘못된 Bearer 차단, MCP 실제 조회까지 확인합니다. GUI 계정 준비 전에는 개인 계정으로 상시 공개 중계를 대신 실행하지 않습니다. diff --git a/scripts/relay/browser-environment.ts b/scripts/relay/browser-environment.ts new file mode 100644 index 00000000..9fd852af --- /dev/null +++ b/scripts/relay/browser-environment.ts @@ -0,0 +1,26 @@ +/** 브라우저에는 운영 토큰 대신 GUI 실행에 필요한 환경만 전달합니다. */ +export function browserEnvironment( + env: NodeJS.ProcessEnv, + crashDir: string, +): Record { + const result: Record = { BREAKPAD_DUMP_LOCATION: crashDir }; + for (const name of [ + 'HOME', + 'PATH', + 'TMPDIR', + 'LANG', + 'LC_ALL', + 'LC_CTYPE', + 'LC_MESSAGES', + 'LC_COLLATE', + 'LC_NUMERIC', + 'LC_TIME', + 'LC_MONETARY', + 'DISPLAY', + 'XAUTHORITY', + '__CF_USER_TEXT_ENCODING', + ]) { + if (env[name] !== undefined) result[name] = env[name]; + } + return result; +} diff --git a/scripts/relay/browser.ts b/scripts/relay/browser.ts index e3ce3d80..ae7454a3 100644 --- a/scripts/relay/browser.ts +++ b/scripts/relay/browser.ts @@ -3,18 +3,47 @@ import type { Page } from 'playwright'; import type { BrowserRunner } from './oliveyoung.js'; export function createBrowserRunner(page: Pick): BrowserRunner { return async (path, body) => { - const result = await page.evaluate(async ({ path, body, timeout }) => { - const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { - method: 'POST', credentials: 'include', - headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, - body: JSON.stringify(body), signal: AbortSignal.timeout(timeout), - }); - const text = await response.text(); - let parsed = null; - try { parsed = JSON.parse(text); } catch { /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ } - return { status: response.status, body: parsed }; - }, { path, body, timeout: 15000 }); - if (result.status !== 200 || result.body?.status !== 'SUCCESS') throw new Error('올리브영 브라우저 응답 실패'); + const result = await page.evaluate( + async ({ path, body, timeout }) => { + const response = await fetch(`https://www.oliveyoung.co.kr${path}`, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(timeout), + }); + const reader = response.body?.getReader(); + if (!reader) throw new Error('Browser response body missing'); + const decoder = new TextDecoder(); + let bytes = 0; + let text = ''; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 2 * 1024 * 1024) { + await reader.cancel(); + throw new Error('Browser response size limit'); + } + text += decoder.decode(chunk.value, { stream: true }); + } + text += decoder.decode(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch { + /* 보안 검증 HTML을 정상 데이터로 반환하지 않습니다. */ + } + return { status: response.status, body: parsed }; + }, + { path, body, timeout: 15000 }, + ); + if (result.status !== 200 || result.body?.status !== 'SUCCESS') + throw new Error('올리브영 브라우저 응답 실패'); return result.body; }; } diff --git a/scripts/relay/guard-controller.ts b/scripts/relay/guard-controller.ts new file mode 100644 index 00000000..8f1bcd8c --- /dev/null +++ b/scripts/relay/guard-controller.ts @@ -0,0 +1,24 @@ +/** 부모의 IPC 단절과 정상 종료를 같은 회수 경로로 처리합니다. */ +export function createGuardController }>( + launch: () => Promise, + release: () => Promise, +) { + let startup: Promise | undefined; + let shutdown: Promise | undefined; + return { + start() { + startup ??= launch(); + return startup; + }, + stop() { + shutdown ??= (async () => { + if (startup) { + const owned = await startup; + await owned.close(); + } + await release(); + })(); + return shutdown; + }, + }; +} diff --git a/scripts/relay/guard-runtime.ts b/scripts/relay/guard-runtime.ts new file mode 100644 index 00000000..c428f3c3 --- /dev/null +++ b/scripts/relay/guard-runtime.ts @@ -0,0 +1,97 @@ +/** 독립 감시 프로세스의 마커·실행·회수를 연결합니다. */ +import { open, unlink, mkdir, rm, realpath } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import type { EventEmitter } from 'node:events'; +import { bounded } from './lifecycle.js'; +import { createGuardController } from './guard-controller.js'; +import { closeOwnedGroup, processSnapshot } from './ownership.js'; +interface OwnedServer { + process(): { pid?: number }; + wsEndpoint(): string; + close(): Promise; + kill(): Promise; +} +interface Channel extends Pick { + connected: boolean; + send(message: unknown): void; + exit(code: number): void; +} +export async function runGuard( + markerPath: string, + launch: (crashDir: string) => Promise, + channel: Channel, + pid: number, + snapshot = processSnapshot, +) { + const marker = await open(markerPath, 'wx', 0o600); + const crashPath = `${markerPath}.${randomUUID()}.crashpad`; + await mkdir(crashPath, { mode: 0o700 }); + const crashDir = await realpath(crashPath); + await marker.writeFile(JSON.stringify({ guardPid: pid, state: 'launching', crashDir })); + await marker.sync(); + const guard = createGuardController( + async () => { + const server = await launch(crashDir); + try { + const root = (await snapshot()).find((row) => row.pid === server.process().pid); + if (!root || root.pid !== root.pgid) throw new Error('Browser ownership unavailable'); + root.crashDir = crashDir; + await marker.truncate(0); + await marker.write(JSON.stringify({ guardPid: pid, browser: root }), 0); + await marker.sync(); + return { + root, + endpoint: server.wsEndpoint(), + close: () => closeOwnedGroup(root, () => server.close(), snapshot), + }; + } catch (error) { + await server.kill(); + throw error; + } + }, + async () => { + await marker.close(); + await rm(crashDir, { recursive: true, force: true }); + await unlink(markerPath); + }, + ); + let stopping = false; + const stop = () => { + if (stopping) return; + stopping = true; + void bounded(guard.stop(), 8000) + .then(() => { + if (channel.connected) channel.send({ type: 'closed' }); + disposeListeners(); + channel.exit(0); + }) + .catch(() => { + disposeListeners(); + channel.exit(1); + }); + }; + channel.once('disconnect', stop); + channel.once('SIGTERM', stop); + channel.once('SIGINT', stop); + const onMessage = (message: unknown) => { + if (message === 'close') stop(); + }; + channel.on('message', onMessage); + const disposeListeners = () => { + for (const event of ['disconnect', 'SIGTERM', 'SIGINT']) channel.off(event, stop); + channel.off('message', onMessage); + }; + try { + const ready = await guard.start(); + if (!channel.connected || stopping) { + stop(); + return; + } + channel.send({ type: 'ready', endpoint: ready.endpoint, root: ready.root }); + } catch { + // 강제 실패에는 마커를 남겨 불확실한 상태에서 후속 실행을 차단합니다. + await marker.close(); + disposeListeners(); + channel.exit(1); + } +} diff --git a/scripts/relay/guard.ts b/scripts/relay/guard.ts new file mode 100644 index 00000000..033d9793 --- /dev/null +++ b/scripts/relay/guard.ts @@ -0,0 +1,33 @@ +/** 브라우저 전용 감시 프로세스 진입점. */ +import { pathToFileURL } from 'node:url'; +import { chromium } from 'playwright'; +import { browserEnvironment } from './browser-environment.js'; +import { runGuard } from './guard-runtime.js'; +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + void runGuard( + process.argv[2], + (crashDir) => + chromium.launchServer({ + headless: false, + chromiumSandbox: true, + env: browserEnvironment(process.env, crashDir), + host: '127.0.0.1', + executablePath: process.env.OY_BROWSER_EXECUTABLE, + handleSIGINT: false, + handleSIGTERM: false, + handleSIGHUP: false, + timeout: 30000, + }), + { + once: process.once.bind(process), + on: process.on.bind(process), + off: process.off.bind(process), + get connected() { + return process.connected; + }, + send: (message) => process.send!(message), + exit: (code) => process.exit(code), + }, + process.pid, + ).catch(() => process.exit(1)); +} diff --git a/scripts/relay/install-macos.py b/scripts/relay/install-macos.py new file mode 100644 index 00000000..4e32bbba --- /dev/null +++ b/scripts/relay/install-macos.py @@ -0,0 +1,180 @@ +#!/usr/bin/python3 +"""검토한 중계 릴리스를 전용 표준 계정에 설치합니다. 기본은 변경 없는 계획 출력입니다.""" +import argparse +import json +import os +from pathlib import Path +import plistlib +import pwd +import shutil +import subprocess +import tempfile + +RUNTIME = Path('/Library/Application Support/DaisoRelay') +USER = 'daisorelay' +LABEL = 'page.aka.daiso-oliveyoung' + +def agent_plist(runtime, home, node): + state = home / 'Library/Application Support/DaisoRelay' + return { + 'Label': LABEL, + 'ProgramArguments': [str(node), '--max-old-space-size=256', '--env-file=' + str(state / 'relay.env'), '--import', 'tsx', str(runtime / 'scripts/relay/start.ts')], + 'WorkingDirectory': str(runtime), + 'EnvironmentVariables': {'HOME': str(home), 'PATH': '/opt/homebrew/bin:/usr/bin:/bin'}, + 'RunAtLoad': True, + 'KeepAlive': {'SuccessfulExit': False}, + 'ThrottleInterval': 60, + 'ExitTimeOut': 30, + 'ProcessType': 'Background', + 'LimitLoadToSessionType': 'Aqua', + 'AbandonProcessGroup': False, + # stdout은 고정된 준비 문장뿐이며 운영 상태는 인증된 /health로 조회합니다. + 'StandardOutPath': '/dev/null', + 'StandardErrorPath': '/dev/null', + } + +def tunnel_plist(home, cloudflared): + return { + 'Label': LABEL + '-tunnel', + 'ProgramArguments': [str(cloudflared), 'tunnel', '--no-autoupdate', 'run', '--token-file', str(home / 'Library/Application Support/DaisoRelay/tunnel-token')], + 'EnvironmentVariables': {'HOME': str(home), 'PATH': '/opt/homebrew/bin:/usr/bin:/bin'}, + 'RunAtLoad': True, 'KeepAlive': True, 'ThrottleInterval': 60, + 'ExitTimeOut': 30, 'AbandonProcessGroup': False, 'LimitLoadToSessionType': 'Aqua', + 'StandardOutPath': '/dev/null', 'StandardErrorPath': '/dev/null', + } + +def validate_tree(root): + if root.is_symlink(): + raise ValueError('Symbolic link source root') + for current, directories, files in os.walk(root): + for name in directories + files: + path = Path(current) / name + if path.is_symlink(): + target = Path(os.readlink(path)) + try: + resolved = path.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise ValueError('Dangling source link') from error + if target.is_absolute() or not resolved.is_relative_to(root): + raise ValueError('Escaping source link') + + +def write_owned(path, content, uid, gid, mode): + descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode) + try: + os.fchown(descriptor, uid, gid) + os.fchmod(descriptor, mode) + with os.fdopen(descriptor, 'wb', closefd=False) as stream: + stream.write(content) + stream.flush() + os.fsync(descriptor) + except BaseException: + path.unlink() + raise + finally: + os.close(descriptor) + + +def install_files(source, browser, runtime, home, user, credentials, node, cloudflared): + for name in ['scripts/relay', 'src', 'node_modules']: + validate_tree(source / name) + validate_tree(browser) + for name in ['package.json', 'package-lock.json', 'tsconfig.json']: + if (source / name).is_symlink(): + raise ValueError('Symbolic link package input') + state = home / 'Library/Application Support/DaisoRelay' + agents = home / 'Library/LaunchAgents' + for target in [state, agents]: + for part in [target, *target.parents]: + if part.is_symlink(): + raise ValueError('Symbolic link destination') + for target in [state / 'relay.env', state / 'tunnel-token', agents / (LABEL + '.plist'), agents / (LABEL + '-tunnel.plist')]: + if target.exists() or target.is_symlink(): + raise ValueError('Existing installation files require explicit upgrade review') + for directory in [state, agents]: + directory.mkdir(parents=True, exist_ok=True) + os.chown(directory, user.pw_uid, user.pw_gid) + os.chmod(state, 0o700) + staging = Path(tempfile.mkdtemp(prefix='DaisoRelay.staging-', dir=runtime.parent)) + created = [] + promoted = False + try: + for name in ['scripts/relay', 'src', 'node_modules']: + shutil.copytree(source / name, staging / name, symlinks=True) + for name in ['package.json', 'package-lock.json', 'tsconfig.json']: + shutil.copy2(source / name, staging / name, follow_symlinks=False) + shutil.copytree(browser, staging / 'Browser.app', symlinks=True) + validate_tree(staging) + for root, dirs, files in os.walk(staging): + for name in dirs + files: + path = Path(root) / name + if not path.is_symlink(): + os.chown(path, os.geteuid(), os.getegid()) + os.chmod(path, path.stat().st_mode & ~0o022) + os.chmod(staging, 0o755) + staging.rename(runtime) + promoted = True + env = { + 'OY_RELAY_TOKEN': credentials['relayToken'], + 'OY_RELAY_STATE_DIR': str(state / 'state'), + 'OY_BROWSER_EXECUTABLE': str(runtime / 'Browser.app/Contents/MacOS/Google Chrome for Testing'), + 'OY_RELAY_PORT': '4319', + } + for path, content in [(state / 'relay.env', ''.join(k + '=' + json.dumps(v) + '\n' for k, v in env.items())), (state / 'tunnel-token', credentials['tunnelToken'])]: + write_owned(path, content.encode(), user.pw_uid, user.pw_gid, 0o600) + created.append(path) + for name, data in [(LABEL, agent_plist(runtime, home, node)), (LABEL + '-tunnel', tunnel_plist(home, cloudflared))]: + path = agents / (name + '.plist') + write_owned(path, plistlib.dumps(data), os.geteuid(), os.getegid(), 0o644) + created.append(path) + except BaseException: + for path in reversed(created): + path.unlink() + shutil.rmtree(runtime if promoted else staging) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2]) + parser.add_argument('--browser-app', type=Path, required=True) + parser.add_argument('--credentials', type=Path, required=True) + parser.add_argument('--apply', action='store_true') + args = parser.parse_args() + source = args.source.resolve() + browser = args.browser_app.resolve() + node = Path('/opt/homebrew/bin/node') + cloudflared = Path('/opt/homebrew/bin/cloudflared') + for path in [source / 'scripts/relay/start.ts', source / 'node_modules/tsx/package.json', browser / 'Contents/MacOS/Google Chrome for Testing', node, cloudflared]: + if not path.exists(): + raise SystemExit('Missing installation input: ' + str(path)) + print('Target account: ' + USER + ' (must be a standard macOS account)') + print('Root-owned runtime: ' + str(RUNTIME)) + print('Install two GUI-session LaunchAgents; no automatic login or immediate external activation.') + if not args.apply: + print('Plan only. Use --apply from an administrator terminal after review.') + return + if os.geteuid() != 0: + raise SystemExit('Run the reviewed installer with sudo in your terminal. Never send passwords in chat.') + try: + user = pwd.getpwnam(USER) + except KeyError: + raise SystemExit('Create the standard account daisorelay in System Settings > Users & Groups first.') + groups = subprocess.check_output(['/usr/bin/id', '-Gn', USER], text=True).split() + if 'admin' in groups or user.pw_uid < 501: + raise SystemExit('Refusing an administrator or system account.') + home = Path(user.pw_dir) + if home != Path('/Users/daisorelay') or home.is_symlink(): + raise SystemExit('Unexpected service account home.') + if RUNTIME.exists(): + raise SystemExit('Runtime already exists. Stop both LaunchAgents and review upgrade separately.') + credentials = json.loads(args.credentials.read_text()) + service = credentials['serviceToken'] + for value in [credentials['relayToken'], credentials['tunnelToken'], service['client_id'], service['client_secret']]: + if not isinstance(value, str) or not value or '\n' in value or '\r' in value: + raise SystemExit('Invalid credential input.') + install_files(source, browser, RUNTIME, home, user, credentials, node, cloudflared) + print('Installed. Log into daisorelay GUI session to start. No auto-login was enabled.') + +if __name__ == '__main__': + main() diff --git a/scripts/relay/lifecycle.ts b/scripts/relay/lifecycle.ts new file mode 100644 index 00000000..64c4836d --- /dev/null +++ b/scripts/relay/lifecycle.ts @@ -0,0 +1,165 @@ +/** 한 브라우저 세션의 재사용·교체·회수를 직렬화합니다. */ +import type { Browser, BrowserContext, Page } from 'playwright'; +import { createBrowserRunner } from './browser.js'; +import type { BrowserRunner } from './oliveyoung.js'; +export interface BrowserOwner { + browser: Pick; + pid: number; + close(): Promise; + rss(): Promise; +} +export async function bounded(work: Promise, ms: number): Promise { + let timer: ReturnType; + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Browser watchdog timeout')), ms); + }), + ]); + } finally { + clearTimeout(timer!); + } +} +export function createBrowserLifecycle(launch: () => Promise) { + let owner: BrowserOwner | undefined; + let page: Page | undefined; + let context: BrowserContext | undefined; + let active = false; + let closed = false; + let failed: unknown; + let cleanup: Promise = Promise.resolve(); + let born = 0; + let calls = 0; + let totalCalls = 0; + let rotations = 0; + let rssBytes = 0; + let reason = 'startup'; + let timer: ReturnType | undefined; + let initializing: Promise | undefined; + const retire = (why: string) => { + reason = why; + if (timer) clearInterval(timer); + timer = undefined; + const previous = owner; + if (!previous) return cleanup; + owner = undefined; + page?.removeAllListeners('crash'); + page?.removeAllListeners('close'); + context?.removeAllListeners('page'); + page = undefined; + context = undefined; + cleanup = bounded(previous.close(), 10000).catch((error) => { + failed = error; + throw error; + }); + // 이벤트 핸들러에서 발생한 실패도 보존하고 다음 요청/종료에 전달합니다. + void cleanup.catch(() => undefined); + return cleanup; + }; + const retireQuietly = (why: string) => { + void retire(why).catch(() => undefined); + }; + const ensureSession = async () => { + await cleanup; + if (closed) throw new Error('Browser lifecycle unavailable'); + if (owner && Date.now() - born >= 30 * 60 * 1000) { + await retire('rotation'); + rotations++; + } + if (owner) return; + owner = await launch(); + born = Date.now(); + calls = 0; + if (closed) { + await retire('shutdown'); + throw new Error('Browser lifecycle unavailable'); + } + try { + context = await bounded(owner.browser.newContext({ locale: 'ko-KR' }), 10000); + // 초기 페이지를 먼저 지정하여 이후 추가 페이지는 모두 닫습니다. + page = await bounded(context.newPage(), 10000); + const sessionOwner = owner; + context.on('page', (popup) => { + void bounded(popup.close(), 3000).catch(() => { + if (owner === sessionOwner) retireQuietly('popup-close-failed'); + }); + }); + page.on('crash', () => retireQuietly('page-crash')); + page.on('close', () => retireQuietly('page-closed')); + await bounded( + page.goto('https://www.oliveyoung.co.kr/', { + waitUntil: 'domcontentloaded', + timeout: 30000, + }), + 32000, + ); + await bounded(page.waitForURL('**/store/**', { timeout: 30000 }), 32000); + timer = setInterval(() => { + if (!active && Date.now() - born >= 30 * 60 * 1000) { + rotations++; + retireQuietly('age-limit'); + return; + } + void bounded(sessionOwner.rss(), 5000) + .then((value) => { + if (owner !== sessionOwner) return; + rssBytes = value; + if (value > 2 ** 30) retireQuietly('rss-limit'); + }) + .catch(() => { + if (owner === sessionOwner) retireQuietly('rss-failed'); + }); + }, 30000); + } catch (error) { + await retire('startup-failed'); + throw error; + } + }; + const ensure = () => { + initializing ??= ensureSession().finally(() => { + initializing = undefined; + }); + return initializing; + }; + const run: BrowserRunner = async (path, body) => { + if (active) throw new Error('Browser already active'); + active = true; + try { + await ensure(); + const result = await bounded(createBrowserRunner(page!)(path, body), 18000); + calls++; + totalCalls++; + if (calls >= 200 || Date.now() - born >= 30 * 60 * 1000) { + rotations++; + await retire('rotation'); + } + return result; + } catch (error) { + await retire('request-failed'); + throw error; + } finally { + active = false; + } + }; + return { + run, + start: ensure, + async close() { + closed = true; + await initializing?.catch(() => undefined); + await retire('shutdown'); + await cleanup; + }, + status: () => ({ + state: failed ? 'failed' : closed ? 'closed' : owner ? 'ready' : 'idle', + active, + calls, + totalCalls, + rotations, + rssBytes, + pages: context?.pages().length || 0, + reason, + }), + }; +} diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 05df4631..bc972418 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -2,7 +2,10 @@ import { timingSafeEqual } from 'node:crypto'; import { OLIVEYOUNG_API } from '../../src/services/oliveyoung/api.js'; import type { OliveyoungApiResponse } from '../../src/services/oliveyoung/types.js'; -export type BrowserRunner = (path: string, body: Record) => Promise; +export type BrowserRunner = ( + path: string, + body: Record, +) => Promise; const paths: Record = { 'find-store': OLIVEYOUNG_API.STORE_FINDER_PATH, 'product-search-v3': OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, @@ -10,58 +13,123 @@ const paths: Record = { 'stock-stores': OLIVEYOUNG_API.STOCK_STORES_PATH, }; const fields: Record> = { - 'find-store': { lat: 'number', lon: 'number', pageIdx: 'number', searchWords: 'string', pogKeys: 'string', serviceKeys: 'string', mapLat: 'number', mapLon: 'number' }, - 'product-search-v3': { includeSoldOut: 'boolean', keyword: 'string', page: 'number', sort: 'string', size: 'number' }, + 'find-store': { + lat: 'number', + lon: 'number', + pageIdx: 'number', + searchWords: 'string', + pogKeys: 'string', + serviceKeys: 'string', + mapLat: 'number', + mapLon: 'number', + }, + 'product-search-v3': { + includeSoldOut: 'boolean', + keyword: 'string', + page: 'number', + sort: 'string', + size: 'number', + }, 'stock-goods-info-v3': { goodsNo: 'string' }, - 'stock-stores': { productId: 'string', lat: 'number', lon: 'number', pageIdx: 'number', searchWords: 'string', mapLat: 'number', mapLon: 'number' }, + 'stock-stores': { + productId: 'string', + lat: 'number', + lon: 'number', + pageIdx: 'number', + searchWords: 'string', + mapLat: 'number', + mapLon: 'number', + }, }; const error = (status: number, message: string) => Response.json({ error: message }, { status }); -export function createOliveyoungRelay(token: string, run: BrowserRunner) { +interface RelayOptions { + takeQuota?: () => Promise; + status?: () => Record; +} +export function createOliveyoungRelay( + token: string, + run: BrowserRunner, + options: RelayOptions = {}, +) { if (!token.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); const expected = Buffer.from(`Bearer ${token}`); let outstanding = 0; let tail: Promise = Promise.resolve(); return async (request: Request): Promise => { const provided = Buffer.from(request.headers.get('authorization') || ''); - if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) return error(401, 'Unauthorized'); + if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) + return error(401, 'Unauthorized'); const url = new URL(request.url); - const operation = url.pathname.replace(/^\/v1\/oliveyoung\//, ''); - if (request.method !== 'POST' || url.search || !Object.hasOwn(paths, operation)) return error(404, 'Not found'); - let body: Record; - try { - const reader = request.body?.getReader(); - if (!reader) return error(400, 'JSON body required'); - const chunks: Uint8Array[] = []; let size = 0; - while (true) { - const next = await reader.read(); - if (next.done) break; - size += next.value.byteLength; - if (size > 16384) { await reader.cancel(); return error(413, 'Body too large'); } - chunks.push(next.value); - } - body = JSON.parse(Buffer.concat(chunks).toString('utf8')); - const schema = fields[operation]; - if (!body || Array.isArray(body) || typeof body !== 'object' || - Object.keys(body).some((key) => !Object.hasOwn(schema, key)) || - Object.entries(schema).some(([key, type]) => typeof body[key] !== type || - (type === 'number' && !Number.isFinite(body[key])))) return error(400, 'Invalid payload'); - } catch { return error(400, 'Invalid JSON'); } - // 한 브라우저 작업과 대기 요청을 합쳐 최대 8건만 수용합니다. + if (request.method === 'GET' && url.pathname === '/health' && !url.search && options.status) { + return Response.json({ ...options.status(), outstanding }); + } + if (!url.pathname.startsWith('/v1/oliveyoung/')) return error(404, 'Not found'); + const operation = url.pathname.slice('/v1/oliveyoung/'.length); + if (request.method !== 'POST' || url.search || !Object.hasOwn(paths, operation)) + return error(404, 'Not found'); + // 느린 업로드도 슬롯을 점유하므로 본문 읽기 전에 상한을 검사합니다. if (outstanding >= 8) return error(503, 'Relay busy'); outstanding++; - const queuedAt = Date.now(); - const task = tail.then(() => { - if (request.signal.aborted || Date.now() - queuedAt >= 15000) return null; - return run(paths[operation], body); - }); - tail = task.catch(() => undefined); try { - const result = await task; - if (result === null) return error(503, 'Request expired'); - if (result?.status !== 'SUCCESS') return error(502, 'Upstream unavailable'); - return Response.json(result); - } catch { return error(502, 'Upstream unavailable'); } - finally { outstanding--; } + let body: Record; + try { + const reader = request.body?.getReader(); + if (!reader) return error(400, 'JSON body required'); + const chunks: Uint8Array[] = []; + let size = 0; + while (true) { + const next = await reader.read(); + if (next.done) break; + size += next.value.byteLength; + if (size > 16384) { + await reader.cancel(); + return error(413, 'Body too large'); + } + chunks.push(next.value); + } + body = JSON.parse(Buffer.concat(chunks).toString('utf8')); + const schema = fields[operation]; + if ( + !body || + Array.isArray(body) || + typeof body !== 'object' || + Object.keys(body).some((key) => !Object.hasOwn(schema, key)) || + Object.entries(schema).some( + ([key, type]) => + typeof body[key] !== type || (type === 'number' && !Number.isFinite(body[key])), + ) + ) + return error(400, 'Invalid payload'); + } catch { + return error(400, 'Invalid JSON'); + } + + const queuedAt = Date.now(); + const task = tail.then(async () => { + if (request.signal.aborted || Date.now() - queuedAt >= 15000) return null; + if (options.takeQuota) { + try { + if (!(await options.takeQuota())) return 'quota' as const; + } catch { + return 'unavailable' as const; + } + } + return run(paths[operation], body); + }); + tail = task.catch(() => undefined); + try { + const result = await task; + if (result === null) return error(503, 'Request expired'); + if (result === 'quota') return error(429, 'Relay quota exceeded'); + if (result === 'unavailable') return error(503, 'Relay quota unavailable'); + if (result?.status !== 'SUCCESS') return error(502, 'Upstream unavailable'); + return Response.json(result); + } catch { + return error(502, 'Upstream unavailable'); + } + } finally { + outstanding--; + } }; } diff --git a/scripts/relay/ownership.ts b/scripts/relay/ownership.ts new file mode 100644 index 00000000..002b0422 --- /dev/null +++ b/scripts/relay/ownership.ts @@ -0,0 +1,106 @@ +/** PID와 생성 시각을 함께 확인하여 전용 브라우저 그룹만 회수합니다. */ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; +import { bounded } from './lifecycle.js'; +export interface ProcessIdentity { + pid: number; + ppid: number; + pgid: number; + rss: number; + birth: string; + command?: string; + crashDir?: string; +} +export function parseProcesses(text: string): ProcessIdentity[] { + return text + .trim() + .split('\n') + .filter(Boolean) + .map((line) => { + const parts = line.trim().split(/\s+/); + if (parts.length < 9 || parts.slice(0, 4).some((part) => !/^\d+$/.test(part))) + throw new Error('Invalid process snapshot'); + return { + pid: Number(parts[0]), + ppid: Number(parts[1]), + pgid: Number(parts[2]), + rss: Number(parts[3]) * 1024, + birth: parts.slice(4, 9).join(' '), + command: parts.slice(9).join(' '), + }; + }); +} +export async function processSnapshot(): Promise { + const { stdout } = await promisify(execFile)( + '/bin/ps', + ['-axo', 'pid=,ppid=,pgid=,rss=,lstart=,command='], + { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, + ); + return parseProcesses(stdout); +} +const same = (a: ProcessIdentity, b: ProcessIdentity) => + a.pid === b.pid && a.birth === b.birth && a.pgid === b.pgid; +const crashHelper = (root: ProcessIdentity, row: ProcessIdentity) => { + if (!root.crashDir || !row.command?.includes('chrome_crashpad_handler ')) return false; + const marker = `--database=${root.crashDir}`; + return row.command.endsWith(marker) || row.command.includes(`${marker} `); +}; +const members = (root: ProcessIdentity, rows: ProcessIdentity[]) => + rows.filter((row) => row.pgid === root.pgid || crashHelper(root, row)); +export function ownedGroup(root: ProcessIdentity, rows: ProcessIdentity[]): ProcessIdentity[] { + if (root.pid !== root.pgid || !rows.some((row) => same(root, row))) + throw new Error('Browser process identity uncertain'); + return members(root, rows); +} +export async function closeOwnedGroup( + root: ProcessIdentity, + close: () => Promise, + snapshot = processSnapshot, + kill: (pid: number, signal: NodeJS.Signals) => unknown = process.kill, +): Promise { + const initial = await snapshot(); + const group = members(root, initial); + if (!group.length) { + await bounded(close(), 3000); + return; + } + // 루트 프로세스가 이미 종료되었으면 소유권을 새로 추정하지 않습니다. + const known = ownedGroup(root, initial); + const closing = close(); + try { + await bounded(closing, 3000); + } catch { + /* 아래에서 정체를 확인한 그룹만 강제 회수합니다. */ + } + const remaining = members(root, await snapshot()); + if (!remaining.length) { + await bounded(closing, 3000); + return; + } + const remainingGroup = remaining.filter((row) => row.pgid === root.pgid); + const helpers = remaining.filter((row) => crashHelper(root, row)); + if ( + (remainingGroup.length && + !remainingGroup.some((row) => known.some((previous) => same(previous, row)))) || + helpers.some((row) => !known.some((previous) => same(previous, row))) + ) { + throw new Error('Browser process identity uncertain'); + } + const targets = helpers.map((row) => row.pid); + if (remainingGroup.length) targets.unshift(-root.pgid); + for (const target of targets) { + try { + kill(target, 'SIGKILL'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error; + } + } + for (let attempt = 0; attempt < 30; attempt++) { + if (!members(root, await snapshot()).length) { + await bounded(closing, 3000); + return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error('Browser process group still alive'); +} diff --git a/scripts/relay/quota.ts b/scripts/relay/quota.ts new file mode 100644 index 00000000..76df3d9f --- /dev/null +++ b/scripts/relay/quota.ts @@ -0,0 +1,47 @@ +/** 단일 중계 프로세스에서 사용하는 재시작 보존 호출 상한. */ +import { readFile, rename, writeFile } from 'node:fs/promises'; +interface Ledger { + day: number; + minute: number; + dayCount: number; + minuteCount: number; +} +export async function createFileQuota( + path: string, + now: () => number = Date.now, +): Promise<() => Promise> { + let ledger: Ledger = { day: 0, minute: 0, dayCount: 0, minuteCount: 0 }; + try { + const value = JSON.parse(await readFile(path, 'utf8')) as Ledger; + if ( + !value || + ![value.day, value.minute, value.dayCount, value.minuteCount].every( + (n) => Number.isSafeInteger(n) && n >= 0, + ) + ) { + throw new Error('Invalid quota ledger'); + } + ledger = value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + let tail: Promise = Promise.resolve(); + return () => { + const task = tail.then(async () => { + const time = now(); + const day = Math.max(ledger.day, Math.floor(time / 86400000)); + const minute = Math.max(ledger.minute, Math.floor(time / 60000)); + const dayCount = day === ledger.day ? ledger.dayCount : 0; + const minuteCount = minute === ledger.minute ? ledger.minuteCount : 0; + if (dayCount >= 3000 || minuteCount >= 30) return false; + const next = { day, minute, dayCount: dayCount + 1, minuteCount: minuteCount + 1 }; + // 기록 실패를 재시도해도 프로세스 내에서 이미 소비한 예산은 되돌리지 않습니다. + ledger = next; + await writeFile(`${path}.tmp`, JSON.stringify(next), { mode: 0o600, flush: true }); + await rename(`${path}.tmp`, path); + return true; + }); + tail = task.catch(() => undefined); + return task; + }; +} diff --git a/scripts/relay/start.ts b/scripts/relay/start.ts index 182f743a..b0ed9fb3 100644 --- a/scripts/relay/start.ts +++ b/scripts/relay/start.ts @@ -2,36 +2,52 @@ import { createServer } from 'node:http'; import { Readable } from 'node:stream'; import { pathToFileURL } from 'node:url'; -import { chromium } from 'playwright'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createBrowserLifecycle } from './lifecycle.js'; +import { launchGuardedBrowser } from './supervisor.js'; +import { createFileQuota } from './quota.js'; import { createOliveyoungRelay } from './oliveyoung.js'; -import { createBrowserRunner } from './browser.js'; async function main() { const token = process.env.OY_RELAY_TOKEN; if (!token?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); const port = Number(process.env.OY_RELAY_PORT || 4319); - if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('OY_RELAY_PORT 값이 잘못되었습니다.'); - const browser = await chromium.launch({ headless: false, executablePath: process.env.OY_BROWSER_EXECUTABLE }); + if (!Number.isInteger(port) || port < 1024 || port > 65535) + throw new Error('OY_RELAY_PORT 값이 잘못되었습니다.'); + const stateDir = process.env.OY_RELAY_STATE_DIR; + if (!stateDir) throw new Error('OY_RELAY_STATE_DIR이 필요합니다.'); + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + const takeQuota = await createFileQuota(join(stateDir, 'quota.json')); + const lifecycle = createBrowserLifecycle(() => + launchGuardedBrowser(join(stateDir, 'browser-owner.json')), + ); try { - const context = await browser.newContext({ locale: 'ko-KR' }); - const page = await context.newPage(); - await page.goto('https://www.oliveyoung.co.kr/', { waitUntil: 'domcontentloaded', timeout: 30000 }); - await page.waitForURL('**/store/**', { timeout: 30000 }); - const handler = createOliveyoungRelay(token, createBrowserRunner(page)); + await lifecycle.start(); + const handler = createOliveyoungRelay(token, lifecycle.run, { + takeQuota, + status: lifecycle.status, + }); const server = createServer(async (req, res) => { try { const controller = new AbortController(); res.once('close', () => controller.abort()); const request = new Request(`http://127.0.0.1:${port}${req.url}`, { - signal: controller.signal, method: req.method, headers: req.headers as Record, + signal: controller.signal, + method: req.method, + headers: req.headers as Record, body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Readable.toWeb(req), duplex: 'half', } as RequestInit & { duplex: 'half' }); const response = await handler(request); - res.writeHead(response.status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(response.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(await response.text()); } catch { - res.writeHead(500); res.end('{"error":"Relay failure"}'); + res.writeHead(500); + res.end('{"error":"Relay failure"}'); } }); server.requestTimeout = 10000; @@ -41,10 +57,27 @@ async function main() { server.listen(port, '127.0.0.1', resolve); }); console.log(`Oliveyoung relay ready on 127.0.0.1:${port}`); - const close = () => { server.close(); void browser.close().then(() => process.exit(0)); }; - process.once('SIGTERM', close); process.once('SIGINT', close); - } catch (error) { await browser.close(); throw error; } + let stopping = false; + const close = () => { + if (stopping) return; + stopping = true; + server.close(); + server.closeAllConnections(); + void lifecycle + .close() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); + }; + process.once('SIGTERM', close); + process.once('SIGINT', close); + } catch (error) { + await lifecycle.close(); + throw error; + } } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { - main().catch(() => { console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 GUI 브라우저 접속을 확인하세요.'); process.exitCode = 1; }); + main().catch(() => { + console.error('올리브영 릴레이 시작 실패: 토큰, 포트 및 GUI 브라우저 접속을 확인하세요.'); + process.exitCode = 1; + }); } diff --git a/scripts/relay/supervisor.ts b/scripts/relay/supervisor.ts new file mode 100644 index 00000000..07c88e18 --- /dev/null +++ b/scripts/relay/supervisor.ts @@ -0,0 +1,48 @@ +/** 독립 감시 프로세스와 연결하고 소유 브라우저의 종료 확인을 기다립니다. */ +import { fork } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { chromium } from 'playwright'; +import { bounded, type BrowserOwner } from './lifecycle.js'; +import { ownedGroup, processSnapshot, type ProcessIdentity } from './ownership.js'; + +export async function launchGuardedBrowser(markerPath: string): Promise { + const guard = fork(fileURLToPath(new URL('./guard.ts', import.meta.url)), [markerPath], { + stdio: ['ignore', 'ignore', 'ignore', 'ipc'], + }); + let exited = false; + const exit = new Promise((resolve) => + guard.once('exit', (code) => { + exited = true; + resolve(code); + }), + ); + const closed = async () => { + if (!exited && guard.connected) guard.send('close'); + const code = await bounded(exit, 9000); + if (code !== 0) throw new Error('Browser guard cleanup unconfirmed'); + }; + try { + const ready = await bounded( + new Promise<{ endpoint: string; root: ProcessIdentity }>((resolve, reject) => { + guard.once('error', reject); + guard.on('message', (message) => { + const data = message as { type: string; endpoint: string; root: ProcessIdentity }; + if (data.type === 'ready') resolve(data); + }); + void exit.then(() => reject(new Error('Browser guard exited before ready'))); + }), + 40000, + ); + const browser = await bounded(chromium.connect(ready.endpoint), 10000); + return { + browser, + pid: ready.root.pid, + close: closed, + rss: async () => + ownedGroup(ready.root, await processSnapshot()).reduce((sum, row) => sum + row.rss, 0), + }; + } catch (error) { + await closed(); + throw error; + } +} diff --git a/src/api/handlers.ts b/src/api/handlers.ts index ca0c0c65..53d08a9f 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -63,6 +63,8 @@ export async function handleOliveyoungSearchProducts(c: ApiContext) { { relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, timeout: timeoutMs, }, ); @@ -108,6 +110,8 @@ export async function handleOliveyoungFindStores(c: ApiContext) { { relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, timeout: timeoutMs, } ); @@ -161,6 +165,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { { relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, timeout: timeoutMs, } ), @@ -175,6 +181,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { { relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, timeout: timeoutMs, } ), @@ -190,6 +198,8 @@ export async function handleOliveyoungCheckInventory(c: ApiContext) { { relayUrl: c.env?.OY_RELAY_URL, relayToken: c.env?.OY_RELAY_TOKEN, + accessClientId: c.env?.OY_ACCESS_CLIENT_ID, + accessClientSecret: c.env?.OY_ACCESS_CLIENT_SECRET, timeout: timeoutMs, } ); diff --git a/src/api/response.ts b/src/api/response.ts index d981e58b..549a9732 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -10,6 +10,8 @@ export interface AppBindings { ZYTE_API_KEY?: string; OY_RELAY_URL?: string; OY_RELAY_TOKEN?: string; + OY_ACCESS_CLIENT_ID?: string; + OY_ACCESS_CLIENT_SECRET?: string; GS25_API_KEY?: string; GOOGLE_MAPS_API_KEY?: string; NAVER_CLIENT_ID?: string; diff --git a/src/core/errors.ts b/src/core/errors.ts index 72154cc0..5da9acd6 100644 --- a/src/core/errors.ts +++ b/src/core/errors.ts @@ -96,6 +96,7 @@ export function toStandardErrorDiagnostics( const oliveyoungRelayConfigurationError = [ '올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.', 'OY_RELAY_TOKEN이 필요합니다.', + '올리브영 Access 서비스 토큰 설정이 필요합니다.', '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', ].includes(message); const retryable = diff --git a/src/index.ts b/src/index.ts index 29910c2f..34fe35f2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,6 +114,8 @@ const createRegistry = (bindings?: AppBindings) => { createOliveyoungService({ relayUrl: bindings?.OY_RELAY_URL, relayToken: bindings?.OY_RELAY_TOKEN, + accessClientId: bindings?.OY_ACCESS_CLIENT_ID, + accessClientSecret: bindings?.OY_ACCESS_CLIENT_SECRET, }), ]); diff --git a/src/services/oliveyoung/index.ts b/src/services/oliveyoung/index.ts index 9b322f67..94887256 100644 --- a/src/services/oliveyoung/index.ts +++ b/src/services/oliveyoung/index.ts @@ -19,6 +19,8 @@ interface OliveyoungServiceOptions { zyteApiKey?: string; relayUrl?: string; relayToken?: string; + accessClientId?: string; + accessClientSecret?: string; } class OliveyoungService implements ServiceProvider { diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index 472322ee..6d9315f2 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -9,6 +9,8 @@ export interface OliveyoungRequestOptions { timeout?: number; relayUrl?: string; relayToken?: string; + accessClientId?: string; + accessClientSecret?: string; } export async function requestOliveyoung( @@ -18,34 +20,60 @@ export async function requestOliveyoung( ): Promise { const { timeout = 15000, relayUrl, relayToken } = options; const headers: Record = { - 'Content-Type': 'application/json', Accept: 'application/json', + 'Content-Type': 'application/json', + Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest', }; let url = `${OLIVEYOUNG_API.BASE_URL}${path}`; if (relayUrl) { let relay: URL; - try { relay = new URL(relayUrl); } catch { + try { + relay = new URL(relayUrl); + } catch { throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); } - if (relay.username || relay.password || relay.search || relay.hash || - (relay.protocol !== 'https:' && !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname)))) { + if ( + relay.username || + relay.password || + relay.search || + relay.hash || + (relay.protocol !== 'https:' && + !(relay.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(relay.hostname))) + ) { throw new Error('올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.'); } if (!relayToken?.trim()) throw new Error('OY_RELAY_TOKEN이 필요합니다.'); url = `${relayUrl.replace(/\/$/, '')}/v1/oliveyoung/${path.split('/').pop()}`; headers.Authorization = `Bearer ${relayToken}`; + const { accessClientId, accessClientSecret } = options; + if (accessClientId !== undefined || accessClientSecret !== undefined) { + if (!accessClientId?.trim() || !accessClientSecret?.trim()) { + throw new Error('올리브영 Access 서비스 토큰 설정이 필요합니다.'); + } + headers['CF-Access-Client-Id'] = accessClientId; + headers['CF-Access-Client-Secret'] = accessClientSecret; + } } let result: OliveyoungApiResponse; try { result = await fetchJson(url, { - method: 'POST', headers, body: JSON.stringify(body), timeout, retries: 0, expectedStatus: 200, + method: 'POST', + redirect: 'error', + headers, + body: JSON.stringify(body), + timeout, + retries: 0, + expectedStatus: 200, }); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw new Error('올리브영 API 요청 시간 초과'); } if (relayUrl) throw new Error('올리브영 브라우저 릴레이 요청 실패'); - throw new Error('올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', { cause: error }); + throw new Error( + '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', + { cause: error }, + ); } if (result?.status !== 'SUCCESS') { throw new Error(`올리브영 API 상태 오류: ${result?.status || 'UNKNOWN'}`); diff --git a/tests/api/response.test.ts b/tests/api/response.test.ts index 12ae7af4..f9c1de9d 100644 --- a/tests/api/response.test.ts +++ b/tests/api/response.test.ts @@ -123,6 +123,7 @@ describe('올리브영 무료 중계 설정 안내', () => { it.each([ '올리브영 릴레이 URL은 HTTPS 또는 로컬 HTTP 주소여야 합니다.', 'OY_RELAY_TOKEN이 필요합니다.', + '올리브영 Access 서비스 토큰 설정이 필요합니다.', '올리브영 직접 요청 실패. 운영자는 OY_RELAY_URL과 OY_RELAY_TOKEN으로 브라우저 릴레이를 설정해주세요.', ])('설정이 필요한 오류는 재시도를 권하지 않는다: %s', (message) => { expect(toStandardErrorDiagnostics('OLIVEYOUNG_PRODUCT_SEARCH_FAILED', message, { status: 500 })) diff --git a/tests/app/app-api-oliveyoung.test.ts b/tests/app/app-api-oliveyoung.test.ts index fa405374..0732ab03 100644 --- a/tests/app/app-api-oliveyoung.test.ts +++ b/tests/app/app-api-oliveyoung.test.ts @@ -156,10 +156,10 @@ describe('GET /api/oliveyoung/inventory', () => { it('Worker 바인딩의 릴레이 설정을 REST 상품 검색에 전달한다', async () => { mockFetch.mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 0 } })); const response = await app.request('/api/oliveyoung/products?keyword=relay-binding-proof', undefined, { - OY_RELAY_URL: 'https://relay.example', OY_RELAY_TOKEN: 'trusted-token', + OY_RELAY_URL: 'https://relay.example', OY_RELAY_TOKEN: 'trusted-token', OY_ACCESS_CLIENT_ID: 'access-id', OY_ACCESS_CLIENT_SECRET: 'access-secret', }); expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/product-search-v3', expect.objectContaining({ - headers: expect.objectContaining({ Authorization: 'Bearer trusted-token' }), + headers: expect.objectContaining({ Authorization: 'Bearer trusted-token', 'CF-Access-Client-Id':'access-id', 'CF-Access-Client-Secret':'access-secret' }), })); }); diff --git a/tests/app/app-mcp-client.test.ts b/tests/app/app-mcp-client.test.ts index 9c1f86a6..48e08a87 100644 --- a/tests/app/app-mcp-client.test.ts +++ b/tests/app/app-mcp-client.test.ts @@ -144,3 +144,14 @@ it('MCP도 키가 남아 있어도 유료 호출 없이 정책 오류를 전달 expect(mockFetch.mock.calls.every(([url]) => new URL(String(url)).hostname !== 'api.zyte.com')).toBe(true); } finally { await client.close(); } }); +it('MCP 올리브영 요청은 운영 바인딩의 Access 자격증명만 사용한다', async () => { + const { __testOnlyClearOliveyoungCaches } = await import('../../src/services/oliveyoung/client.js'); + __testOnlyClearOliveyoungCaches(); + mockFetch.mockImplementation(async () => Response.json({status:'SUCCESS',data:{}})); + const client=await createLocalMcpClient({OY_RELAY_URL:'https://relay.example',OY_RELAY_TOKEN:'relay-test',OY_ACCESS_CLIENT_ID:'access-test-id',OY_ACCESS_CLIENT_SECRET:'access-test-secret'}); + try { + const result=await client.callTool({name:'oliveyoung_search_products',arguments:{keyword:'팩',accessClientSecret:'evil'}}); + expect(result.isError).not.toBe(true); + expect(mockFetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/product-search-v3',expect.objectContaining({redirect:'error',headers:expect.objectContaining({'CF-Access-Client-Id':'access-test-id','CF-Access-Client-Secret':'access-test-secret'})})); + }finally{await client.close();} +}); diff --git a/tests/relay/browser-environment.test.ts b/tests/relay/browser-environment.test.ts new file mode 100644 index 00000000..61bead6e --- /dev/null +++ b/tests/relay/browser-environment.test.ts @@ -0,0 +1,27 @@ +import { expect, it } from 'vitest'; +import { browserEnvironment } from '../../scripts/relay/browser-environment.js'; +it('GUI 필수 환경만 전달하고 토큰 및 임의 로더 설정을 제거한다', () => { + expect( + browserEnvironment( + { + HOME: '/dedicated', + PATH: '/bin', + LANG: 'ko_KR', + LC_CTYPE: 'UTF-8', + DISPLAY: ':0', + OY_RELAY_TOKEN: 'secret', + CF_ACCESS_CLIENT_SECRET: 'secret', + NODE_OPTIONS: 'injected', + BREAKPAD_DUMP_LOCATION: '/other', + }, + '/owned', + ), + ).toEqual({ + HOME: '/dedicated', + PATH: '/bin', + LANG: 'ko_KR', + LC_CTYPE: 'UTF-8', + DISPLAY: ':0', + BREAKPAD_DUMP_LOCATION: '/owned', + }); +}); diff --git a/tests/relay/browser.test.ts b/tests/relay/browser.test.ts index af5603e2..3faf3376 100644 --- a/tests/relay/browser.test.ts +++ b/tests/relay/browser.test.ts @@ -1,26 +1,75 @@ import { expect, it, vi } from 'vitest'; import { createBrowserRunner } from '../../scripts/relay/browser.js'; it('성공 JSON만 반환하고 브라우저 fetch에 제한시간을 설정한다', async () => { - const page = { evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }) }; + const page = { + evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }), + }; const run = createBrowserRunner(page); - expect(await run('/oystore/api/stock/stock-goods-info-v3', { goodsNo: 'A1' })).toEqual({ status: 'SUCCESS' }); - expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), expect.objectContaining({ timeout: 15000 })); - for (const response of [{status: 403, body: {}}, {status: 200, body: null}, {status: 200, body: {status:'FAIL'}}]) { + expect(await run('/oystore/api/stock/stock-goods-info-v3', { goodsNo: 'A1' })).toEqual({ + status: 'SUCCESS', + }); + expect(page.evaluate).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ timeout: 15000 }), + ); + for (const response of [ + { status: 403, body: {} }, + { status: 200, body: null }, + { status: 200, body: { status: 'FAIL' } }, + ]) { page.evaluate.mockResolvedValue(response); await expect(run('/path', {})).rejects.toThrow('올리브영 브라우저 응답 실패'); } }); it('브라우저 컨텍스트에서 JSON 요청과 응답을 처리하고 HTML은 거절한다', async () => { - const fetch = vi.fn().mockResolvedValue(Response.json({status:'SUCCESS'})); + const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS' })); vi.stubGlobal('fetch', fetch); const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; const run = createBrowserRunner(page); try { - expect(await run('/oystore/api/stock/stock-goods-info-v3', {goodsNo:'A1'})).toEqual({status:'SUCCESS'}); - expect(fetch).toHaveBeenCalledWith('https://www.oliveyoung.co.kr/oystore/api/stock/stock-goods-info-v3', expect.objectContaining({method:'POST',credentials:'include',body:'{"goodsNo":"A1"}',signal:expect.any(AbortSignal)})); + expect(await run('/oystore/api/stock/stock-goods-info-v3', { goodsNo: 'A1' })).toEqual({ + status: 'SUCCESS', + }); + expect(fetch).toHaveBeenCalledWith( + 'https://www.oliveyoung.co.kr/oystore/api/stock/stock-goods-info-v3', + expect.objectContaining({ + method: 'POST', + credentials: 'include', + body: '{"goodsNo":"A1"}', + signal: expect.any(AbortSignal), + }), + ); fetch.mockResolvedValue(new Response('challenge')); await expect(run('/p', {})).rejects.toThrow('브라우저 응답 실패'); fetch.mockRejectedValue(new Error('network')); await expect(run('/p', {})).rejects.toThrow('network'); - } finally { vi.unstubAllGlobals(); } + } finally { + vi.unstubAllGlobals(); + } +}); +it('2MiB를 넘는 원본 응답은 읽기를 취소한다', async () => { + const cancel = vi.fn(); + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(2 * 1024 * 1024 + 1)); + }, + cancel, + }); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(stream))); + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + try { + await expect(createBrowserRunner(page)('/p', {})).rejects.toThrow('response size'); + expect(cancel).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllGlobals(); + } +}); +it('본문 없는 HTTP 응답을 거절한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null))); + const page = { evaluate: vi.fn().mockImplementation((fn, args) => fn(args)) }; + try { + await expect(createBrowserRunner(page)('/p', {})).rejects.toThrow('body missing'); + } finally { + vi.unstubAllGlobals(); + } }); diff --git a/tests/relay/guard-runtime.test.ts b/tests/relay/guard-runtime.test.ts new file mode 100644 index 00000000..2cff7d04 --- /dev/null +++ b/tests/relay/guard-runtime.test.ts @@ -0,0 +1,77 @@ +import { EventEmitter } from 'node:events'; +import { mkdtemp, readFile, access, rm } from 'node:fs/promises'; +import { afterEach, expect, it, vi } from 'vitest'; +import { runGuard } from '../../scripts/relay/guard-runtime.js'; +const dirs: string[] = []; +afterEach(async () => { + for (const dir of dirs) await rm(dir, { recursive: true, force: true }); +}); +async function fixture() { + const dir = await mkdtemp('/tmp/guard-unit-'); + dirs.push(dir); + const marker = dir + '/owner.json'; + const root = { pid: 123, ppid: 1, pgid: 123, rss: 1, birth: 'start' }; + const snapshot = vi.fn().mockResolvedValue([root]); + const server = { + process: () => ({ pid: 123 }), + wsEndpoint: () => 'ws://127.0.0.1/browser', + close: vi.fn().mockImplementation(async () => { + snapshot.mockResolvedValue([]); + }), + kill: vi.fn().mockResolvedValue(undefined), + }; + const channel = Object.assign(new EventEmitter(), { + connected: true, + send: vi.fn(), + exit: vi.fn(), + }); + return { marker, root, snapshot, server, channel }; +} +it('소유 마커를 기록하고 부모 단절 시 종료 확인 후 삭제한다', async () => { + const f = await fixture(); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + expect(JSON.parse(await readFile(f.marker, 'utf8')).browser.pid).toBe(123); + expect(f.channel.send).toHaveBeenCalledWith(expect.objectContaining({ type: 'ready' })); + f.channel.connected = false; + f.channel.emit('disconnect'); + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); + await expect(access(f.marker)).rejects.toThrow(); +}); +it('정상 IPC 종료 및 중복 종료는 한 번만 처리한다', async () => { + const f = await fixture(); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + f.channel.emit('message', 'noop'); + f.channel.emit('message', 'close'); + f.channel.emit('SIGTERM'); + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); + expect(f.server.close).toHaveBeenCalledTimes(1); + expect(f.channel.send).toHaveBeenCalledWith({ type: 'closed' }); +}); +it('실행 실패와 잘못된 소유권은 마커를 남긴다', async () => { + const f = await fixture(); + f.snapshot.mockResolvedValue([]); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + expect(f.server.kill).toHaveBeenCalledTimes(1); + expect(f.channel.exit).toHaveBeenCalledWith(1); + await access(f.marker); + await expect( + runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot), + ).rejects.toThrow(); +}); +it('시작 당시 부모가 없으면 준비 상태를 보내지 않고 정리한다', async () => { + const f = await fixture(); + f.channel.connected = false; + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(0)); + expect(f.channel.send).not.toHaveBeenCalled(); + expect(f.channel.listenerCount('message')).toBe(0); +}); +it('회수 중 소유권이 사라지면 마커를 보존하고 실패 종료한다', async () => { + const f = await fixture(); + await runGuard(f.marker, async () => f.server, f.channel, 1, f.snapshot); + f.snapshot.mockResolvedValue([{ ...f.root, pid: 124 }]); + f.channel.emit('SIGINT'); + await vi.waitFor(() => expect(f.channel.exit).toHaveBeenCalledWith(1)); + await access(f.marker); + expect(f.channel.listenerCount('message')).toBe(0); +}); diff --git a/tests/relay/guard.test.ts b/tests/relay/guard.test.ts new file mode 100644 index 00000000..1086e847 --- /dev/null +++ b/tests/relay/guard.test.ts @@ -0,0 +1,53 @@ +import { expect, it, vi } from 'vitest'; +import { createGuardController } from '../../scripts/relay/guard-controller.js'; +it('부모 단절 시 브라우저 종료 확인 후 마커를 해제한다', async () => { + const release = vi.fn().mockResolvedValue(undefined); + const close = vi.fn().mockResolvedValue(undefined); + const guard = createGuardController(async () => ({ close }), release); + await guard.start(); + await guard.stop(); + await guard.stop(); + expect(close).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(close.mock.invocationCallOrder[0]).toBeLessThan(release.mock.invocationCallOrder[0]); +}); +it('실행 중 부모가 단절되어도 늦게 생성된 브라우저를 회수한다', async () => { + let finish!: (value: { close: () => Promise }) => void; + const close = vi.fn().mockResolvedValue(undefined); + const release = vi.fn(); + const guard = createGuardController( + () => + new Promise((resolve) => { + finish = resolve; + }), + release, + ); + const starting = guard.start(); + const stopping = guard.stop(); + finish({ close }); + await starting; + await stopping; + expect(close).toHaveBeenCalledTimes(1); +}); +it('브라우저 종료 실패에는 마커를 남겨 재실행을 차단한다', async () => { + const release = vi.fn(); + const guard = createGuardController( + async () => ({ + close: async () => { + throw new Error('not gone'); + }, + }), + release, + ); + await guard.start(); + await expect(guard.stop()).rejects.toThrow('not gone'); + expect(release).not.toHaveBeenCalled(); +}); +it('실행 전 종료는 브라우저 없이 마커를 해제한다', async () => { + const launch = vi.fn(); + const release = vi.fn().mockResolvedValue(undefined); + const guard = createGuardController(launch, release); + await guard.stop(); + expect(launch).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledTimes(1); +}); diff --git a/tests/relay/lifecycle.test.ts b/tests/relay/lifecycle.test.ts new file mode 100644 index 00000000..36d8c5b8 --- /dev/null +++ b/tests/relay/lifecycle.test.ts @@ -0,0 +1,221 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js'; +afterEach(() => vi.useRealTimers()); +function fixture() { + const page = Object.assign(new EventEmitter(), { + goto: vi.fn().mockResolvedValue(undefined), + waitForURL: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }), + close: vi.fn().mockResolvedValue(undefined), + }); + const context = Object.assign(new EventEmitter(), { + newPage: vi.fn().mockResolvedValue(page), + pages: () => [page], + }); + const owner = { + browser: { newContext: vi.fn().mockResolvedValue(context) }, + close: vi.fn().mockResolvedValue(undefined), + rss: vi.fn().mockResolvedValue(100), + pid: 123, + }; + const launch = vi.fn().mockResolvedValue(owner); + return { page, context, owner, launch }; +} +it('반복 요청은 한 페이지를 재사용하고200회 뒤 이전 세션 종료를 확인한 후 교체한다', async () => { + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + for (let i = 0; i < 201; i++) await life.run('/p', {}); + expect(f.context.newPage).toHaveBeenCalledTimes(2); + expect(f.owner.close).toHaveBeenCalledTimes(1); + expect(f.owner.close.mock.invocationCallOrder[0]).toBeLessThan( + f.launch.mock.invocationCallOrder[1], + ); + await life.close(); + expect(life.status().state).toBe('closed'); +}); +it('종료 확인 실패 후 대체 브라우저를 실행하지 않는다', async () => { + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.run('/p', {}); + f.owner.close.mockRejectedValue(new Error('not gone')); + f.page.emit('crash'); + await expect(life.run('/p', {})).rejects.toThrow(); + expect(f.launch).toHaveBeenCalledTimes(1); + await expect(life.close()).rejects.toThrow(); +}); +it('팝업을 닫고 닫기 실패는 세션을 회수한다', async () => { + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.run('/p', {}); + const popup = { close: vi.fn().mockRejectedValue(new Error('hang')) }; + f.context.emit('page', popup); + await vi.waitFor(() => expect(f.owner.close).toHaveBeenCalledTimes(1)); + await life.close(); + expect(popup.close).toHaveBeenCalledTimes(1); +}); +it('Node watchdog이 멈춘 evaluate를 회수하고 종료 시 타이머를 제거한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.run('/p', {}); + f.page.evaluate.mockImplementation(() => new Promise(() => {})); + const pending = life.run('/p', {}); + const rejected = expect(pending).rejects.toThrow('watchdog'); + await vi.advanceTimersByTimeAsync(18000); + await rejected; + expect(f.owner.close).toHaveBeenCalledTimes(1); + await life.close(); + expect(vi.getTimerCount()).toBe(0); +}); +it('메모리 한도와30분 유휴 수명을 점검한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.run('/p', {}); + f.owner.rss.mockResolvedValue(2 ** 30 + 1); + await vi.advanceTimersByTimeAsync(30000); + expect(f.owner.close).toHaveBeenCalledTimes(1); + f.owner.rss.mockResolvedValue(1); + await life.run('/p', {}); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + await life.run('/p', {}); + expect(f.launch).toHaveBeenCalledTimes(3); + await life.close(); +}); +it('200번째 조회 후 다음 요청 없이 유휴 세션을 회수한다', async () => { + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + for (let i = 0; i < 200; i++) await life.run('/p', {}); + expect(f.owner.close).toHaveBeenCalledTimes(1); + expect(f.launch).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('유휴30분 만료는 다음 요청 없이 회수한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + await vi.advanceTimersByTimeAsync(30 * 60 * 1000); + expect(f.owner.close).toHaveBeenCalledTimes(1); + await life.close(); +}); +it('종료된 수명은 재실행하지 않고 상태를 보고한다', async () => { + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + expect(life.status().state).toBe('idle'); + await life.start(); + expect(life.status()).toMatchObject({ state: 'ready', pages: 1 }); + await life.close(); + await expect(life.run('/p', {})).rejects.toThrow('unavailable'); + expect(f.launch).toHaveBeenCalledTimes(1); +}); +it('시작 중 종료는 늦게 도착한 소유 브라우저도 회수한다', async () => { + const f = fixture(); + let finish!: (v: typeof f.owner) => void; + f.launch.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const life = createBrowserLifecycle(f.launch); + const pending = life.start(); + const rejected = expect(pending).rejects.toThrow('unavailable'); + await Promise.resolve(); + const closing = life.close(); + finish(f.owner); + await rejected; + await closing; + expect(f.context.newPage).not.toHaveBeenCalled(); + expect(f.owner.close).toHaveBeenCalledTimes(1); +}); +it('초기 페이지 접속 실패와 페이지 닫힘을 회수한다', async () => { + const f = fixture(); + f.page.goto.mockRejectedValueOnce(new Error('navigation')); + const life = createBrowserLifecycle(f.launch); + await expect(life.start()).rejects.toThrow('navigation'); + expect(f.owner.close).toHaveBeenCalledTimes(1); + await life.start(); + f.page.emit('close'); + await life.close(); + expect(f.owner.close).toHaveBeenCalledTimes(2); +}); +it('동시 실행을 거절하고 다음 요청에서 만료된 세션을 교체한다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + vi.setSystemTime(Date.now() + 30 * 60 * 1000); + await life.run('/p', {}); + expect(f.launch).toHaveBeenCalledTimes(2); + let finish!: (v: unknown) => void; + f.page.evaluate.mockImplementation( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const pending = life.run('/p', {}); + await vi.advanceTimersByTimeAsync(0); + await expect(life.run('/p', {})).rejects.toThrow('already active'); + finish({ status: 200, body: { status: 'SUCCESS' } }); + await pending; + await life.close(); +}); +it('RSS 점검 실패는 세션 회수와 실패 상태로 이어진다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + f.owner.rss.mockRejectedValue(new Error('ps')); + f.owner.close.mockRejectedValue(new Error('uncertain')); + await vi.advanceTimersByTimeAsync(30000); + expect(life.status().state).toBe('failed'); + await expect(life.close()).rejects.toThrow('uncertain'); + expect(vi.getTimerCount()).toBe(0); +}); +it('이전 세션 팝업 및 RSS 작업은 새 세션을 회수하지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + let rss!: (v: number) => void; + f.owner.rss.mockImplementationOnce( + () => + new Promise((resolve) => { + rss = resolve; + }), + ); + await vi.advanceTimersByTimeAsync(30000); + let fail!: (v: Error) => void; + f.context.emit('page', { + close: () => + new Promise((_, reject) => { + fail = reject; + }), + }); + await life.close(); + rss(2 ** 30 + 1); + fail(new Error('old popup')); + await vi.advanceTimersByTimeAsync(0); + expect(f.owner.close).toHaveBeenCalledTimes(1); +}); +it('이전 세션 RSS 실패는 종료 후 추가 작업을 만들지 않는다', async () => { + vi.useFakeTimers(); + const f = fixture(); + const life = createBrowserLifecycle(f.launch); + await life.start(); + let fail!: (v: Error) => void; + f.owner.rss.mockImplementationOnce( + () => + new Promise((_, reject) => { + fail = reject; + }), + ); + await vi.advanceTimersByTimeAsync(30000); + await life.close(); + fail(new Error('old')); + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index ff321676..69901245 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -1,15 +1,22 @@ import { expect, it, vi } from 'vitest'; import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; const payload = { goodsNo: 'A1' }; -const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => new Request(`http://localhost/v1/oliveyoung/${path}`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body }); +const request = (path = 'stock-goods-info-v3', body = JSON.stringify(payload), token = 'test') => + new Request(`http://localhost/v1/oliveyoung/${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body, + }); it('인증 실패 시 브라우저를 호출하지 않는다', async () => { - const runner = vi.fn(); const relay = createOliveyoungRelay('test', runner); + const runner = vi.fn(); + const relay = createOliveyoungRelay('test', runner); expect((await relay(request(undefined, undefined, 'bad'))).status).toBe(401); expect(runner).not.toHaveBeenCalled(); }); it('허용된 경로와 검증한 JSON만 브라우저로 전달한다', async () => { const result = { status: 'SUCCESS', data: { goodsInfo: {} } }; - const runner = vi.fn().mockResolvedValue(result); const relay = createOliveyoungRelay('test', runner); + const runner = vi.fn().mockResolvedValue(result); + const relay = createOliveyoungRelay('test', runner); expect(await (await relay(request())).json()).toEqual(result); expect(runner).toHaveBeenCalledWith('/oystore/api/stock/stock-goods-info-v3', payload); expect((await relay(request('https://evil.example'))).status).toBe(404); @@ -20,7 +27,12 @@ it('허용된 경로와 검증한 JSON만 브라우저로 전달한다', async ( }); it('브라우저 오류를 숨기고 동시 요청을 제한한다', async () => { let reject!: (reason: Error) => void; - const runner = vi.fn().mockImplementation(() => new Promise((_, fail) => { reject = fail; })); + const runner = vi.fn().mockImplementation( + () => + new Promise((_, fail) => { + reject = fail; + }), + ); const relay = createOliveyoungRelay('test', runner); const first = relay(request()); await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); @@ -37,15 +49,21 @@ it('브라우저 오류를 숨기고 동시 요청을 제한한다', async () => expect((await relay(request())).status).toBe(200); }); it('대기 중 취소된 요청은 브라우저 작업을 생략한다', async () => { - let finish!: (value: {status: string}) => void; - const runner = vi.fn().mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + let finish!: (value: { status: string }) => void; + const runner = vi.fn().mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); const relay = createOliveyoungRelay('test', runner); const first = relay(request()); await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const controller = new AbortController(); const pending = relay(new Request(request(), { signal: controller.signal })); await new Promise((resolve) => setTimeout(resolve, 0)); - controller.abort(); finish({status:'SUCCESS'}); + controller.abort(); + finish({ status: 'SUCCESS' }); await first; expect((await pending).status).toBe(503); expect(runner).toHaveBeenCalledTimes(1); @@ -55,15 +73,111 @@ it('누락 인증, 빈 토큰, 잘못된 메서드와 요청 형태를 거절한 expect(() => createOliveyoungRelay(' ', runner)).toThrow('OY_RELAY_TOKEN'); const relay = createOliveyoungRelay('test', runner); expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store'))).status).toBe(401); - expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store', { headers: {Authorization:'Bearer test'} }))).status).toBe(404); - expect((await relay(new Request('http://localhost/v1/oliveyoung/find-store', {method:'POST',headers:{Authorization:'Bearer test'}}))).status).toBe(400); - for (const body of ['null', '[]', '1', '{"goodsNo":4}', '{"goodsNo":"A1","url":"https://evil.example"}', '{"lat":1e400,"lon":1,"pageIdx":1,"searchWords":"","pogKeys":"","serviceKeys":"","mapLat":1,"mapLon":1}']) { - expect((await relay(request(body.includes('lat')?'find-store':undefined, body))).status).toBe(400); + expect( + ( + await relay( + new Request('http://localhost/v1/oliveyoung/find-store', { + headers: { Authorization: 'Bearer test' }, + }), + ) + ).status, + ).toBe(404); + expect( + ( + await relay( + new Request('http://localhost/v1/oliveyoung/find-store', { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + }), + ) + ).status, + ).toBe(400); + for (const body of [ + 'null', + '[]', + '1', + '{"goodsNo":4}', + '{"goodsNo":"A1","url":"https://evil.example"}', + '{"lat":1e400,"lon":1,"pageIdx":1,"searchWords":"","pogKeys":"","serviceKeys":"","mapLat":1,"mapLon":1}', + ]) { + expect( + (await relay(request(body.includes('lat') ? 'find-store' : undefined, body))).status, + ).toBe(400); } expect((await relay(request('stock-goods-info-v3?x=1'))).status).toBe(404); expect(runner).not.toHaveBeenCalled(); }); it('실패 상태의 브라우저 JSON을 데이터로 전달하지 않는다', async () => { - const relay = createOliveyoungRelay('test', vi.fn().mockResolvedValue({status:'FAIL'})); + const relay = createOliveyoungRelay('test', vi.fn().mockResolvedValue({ status: 'FAIL' })); expect((await relay(request())).status).toBe(502); }); +it('본문을 읽는 중인 요청도 슬롯 상한에 포함한다', async () => { + const runner = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', runner); + const streams: ReadableStreamDefaultController[] = []; + const pending = Array.from({ length: 8 }, () => + relay( + new Request('http://localhost/v1/oliveyoung/stock-goods-info-v3', { + method: 'POST', + headers: { Authorization: 'Bearer test' }, + body: new ReadableStream({ + start(controller) { + streams.push(controller); + }, + }), + duplex: 'half', + } as RequestInit & { duplex: 'half' }), + ), + ); + expect((await relay(request())).status).toBe(503); + for (const controller of streams) { + controller.enqueue(new TextEncoder().encode(JSON.stringify(payload))); + controller.close(); + } + expect((await Promise.all(pending)).every((response) => response.status === 200)).toBe(true); + expect((await relay(request())).status).toBe(200); +}); +it('공유 예산 거절·저장 오류에는 브라우저를 호출하지 않는다', async () => { + const runner = vi.fn(); + const takeQuota = vi.fn().mockResolvedValueOnce(false).mockRejectedValueOnce(new Error('secret')); + const relay = createOliveyoungRelay('test', runner, { takeQuota }); + expect((await relay(request())).status).toBe(429); + expect((await relay(request())).status).toBe(503); + expect(runner).not.toHaveBeenCalled(); +}); +it('인증된 상태 조회만 운영 통계를 반환한다', async () => { + const relay = createOliveyoungRelay('test', vi.fn(), { status: () => ({ pages: 1 }) }); + expect((await relay(new Request('http://localhost/health'))).status).toBe(401); + expect( + await ( + await relay( + new Request('http://localhost/health', { headers: { Authorization: 'Bearer test' } }), + ) + ).json(), + ).toMatchObject({ pages: 1, outstanding: 0 }); +}); +it('예산 허용 후 요청을 실행하고 잘못된 상태 경로는 거절한다', async () => { + const runner = vi.fn().mockResolvedValue({ status: 'SUCCESS' }); + const relay = createOliveyoungRelay('test', runner, { + takeQuota: async () => true, + status: () => ({ pages: 1 }), + }); + expect((await relay(request())).status).toBe(200); + for (const path of ['/health?x=1', '/other']) { + expect( + ( + await relay( + new Request(`http://localhost${path}`, { headers: { Authorization: 'Bearer test' } }), + ) + ).status, + ).toBe(404); + } + expect( + ( + await createOliveyoungRelay( + 'test', + runner, + )(new Request('http://localhost/health', { headers: { Authorization: 'Bearer test' } })) + ).status, + ).toBe(404); +}); diff --git a/tests/relay/ownership.test.ts b/tests/relay/ownership.test.ts new file mode 100644 index 00000000..06c6f2cd --- /dev/null +++ b/tests/relay/ownership.test.ts @@ -0,0 +1,164 @@ +import { expect, it, vi } from 'vitest'; +import { parseProcesses, ownedGroup, closeOwnedGroup } from '../../scripts/relay/ownership.js'; +const root = { pid: 100, ppid: 1, pgid: 100, rss: 200, birth: 'Mon Sep 15 09:00:00 2026' }; +const child = { ...root, pid: 101, ppid: 100, rss: 300 }; +it('프로세스 그룹의 전체RSS를 조회하고 PID 재사용을 거절한다', () => { + const rows = parseProcesses( + ' 100 1 100 200 Mon Sep 15 09:00:00 2026 Chrome\n 101 100 100 300 Mon Sep 15 09:00:00 2026 Renderer', + ); + expect(ownedGroup(root, rows).reduce((sum, p) => sum + p.rss, 0)).toBe(500 * 1024); + expect(() => ownedGroup(root, [{ ...root, birth: 'different' }])).toThrow('identity'); +}); +it('정상 종료 이후 남은 소유 그룹만 강제 종료하고 소멸을 확인한다', async () => { + const snapshot = vi + .fn() + .mockResolvedValueOnce([root, child]) + .mockResolvedValueOnce([child]) + .mockResolvedValue([]); + const kill = vi.fn(); + await closeOwnedGroup(root, async () => {}, snapshot, kill); + expect(kill).toHaveBeenCalledWith(-100, 'SIGKILL'); +}); +it('원래 그룹의 정체를 확인할 수 없으면 강제 종료하지 않는다', async () => { + const snapshot = vi + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([{ ...child, birth: 'new' }]); + const kill = vi.fn(); + await expect(closeOwnedGroup(root, async () => {}, snapshot, kill)).rejects.toThrow('identity'); + expect(kill).not.toHaveBeenCalled(); +}); +it('실제 OS 스냅샷을 읽고 잘못된 형식과 이미 사라진 그룹을 처리한다', async () => { + const { processSnapshot } = await import('../../scripts/relay/ownership.js'); + expect((await processSnapshot()).some((row) => row.pid === process.pid)).toBe(true); + expect(() => parseProcesses('bad')).toThrow('Invalid'); + const close = vi.fn().mockResolvedValue(undefined); + await closeOwnedGroup(root, close, async () => [], vi.fn()); + expect(close).toHaveBeenCalledTimes(1); +}); +it('강제 종료 후에도 남은 프로세스는 확인 실패로 처리한다', async () => { + vi.useFakeTimers(); + const snapshot = vi.fn().mockResolvedValue([root]); + const pending = closeOwnedGroup( + root, + async () => { + throw new Error('close'); + }, + snapshot, + vi.fn(), + ); + const rejected = expect(pending).rejects.toThrow('still alive'); + await vi.advanceTimersByTimeAsync(3000); + await rejected; + vi.useRealTimers(); +}); +it('분리된 Crashpad는 정확한 전용 디렉터리로만 포함하고 회수한다', async () => { + const owned = { ...root, crashDir: '/owned-uuid' }; + const helper = { + ...child, + pid: 201, + pgid: 200, + command: 'chrome_crashpad_handler --database=/owned-uuid --other', + }; + const personal = { + ...helper, + pid: 301, + pgid: 300, + command: 'chrome_crashpad_handler --database=/owned-uuid-other', + }; + expect(ownedGroup(owned, [root, helper, personal]).map((p) => p.pid)).toEqual([100, 201]); + const snapshot = vi + .fn() + .mockResolvedValueOnce([root, helper, personal]) + .mockResolvedValueOnce([helper, personal]) + .mockResolvedValue([personal]); + const kill = vi.fn(); + await closeOwnedGroup(owned, async () => {}, snapshot, kill); + expect(kill).toHaveBeenCalledExactlyOnceWith(201, 'SIGKILL'); + const trailing = { ...helper, command: 'chrome_crashpad_handler --database=/owned-uuid' }; + expect(ownedGroup(owned, [root, trailing])).toHaveLength(2); +}); +it('종료 중 이미 사라진 PID만 허용하고 그 외 kill 오류는 보존한다', async () => { + const snapshot = vi + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([root]) + .mockResolvedValue([]); + await closeOwnedGroup( + root, + async () => {}, + snapshot, + () => { + throw Object.assign(new Error(), { code: 'ESRCH' }); + }, + ); + await expect( + closeOwnedGroup( + root, + async () => {}, + async () => [root], + () => { + throw new Error('denied'); + }, + ), + ).rejects.toThrow('denied'); +}); +it('소유 Crashpad가 남아 있어도 재사용된 다른 프로세스 그룹은 종료하지 않는다', async () => { + const owned = { ...root, crashDir: '/uuid' }; + const helper = { + ...child, + pid: 201, + pgid: 200, + command: 'chrome_crashpad_handler --database=/uuid', + }; + const snapshot = vi + .fn() + .mockResolvedValueOnce([root, helper]) + .mockResolvedValueOnce([{ ...root, birth: 'reused' }, helper]) + .mockResolvedValue([]); + const kill = vi.fn(); + await expect(closeOwnedGroup(owned, async () => {}, snapshot, kill)).rejects.toThrow('identity'); + expect(kill).not.toHaveBeenCalled(); +}); +it('Crashpad PID가 재사용되면 이름과 디렉터리가 같아도 종료하지 않는다', async () => { + const owned = { ...root, crashDir: '/uuid' }; + const helper = { + ...child, + pid: 201, + pgid: 200, + command: 'chrome_crashpad_handler --database=/uuid', + }; + const snapshot = vi + .fn() + .mockResolvedValueOnce([root, helper]) + .mockResolvedValueOnce([{ ...helper, birth: 'new' }]); + const kill = vi.fn(); + await expect(closeOwnedGroup(owned, async () => {}, snapshot, kill)).rejects.toThrow('identity'); + expect(kill).not.toHaveBeenCalled(); +}); +it('강제 종료 후에도 Playwright 프로필 정리 완료를 기다린다', async () => { + vi.useFakeTimers(); + let finish!: () => void; + const closing = new Promise((resolve) => { + finish = resolve; + }); + const snapshot = vi + .fn() + .mockResolvedValueOnce([root]) + .mockResolvedValueOnce([root]) + .mockResolvedValue([]); + let done = false; + const pending = closeOwnedGroup( + root, + () => closing, + snapshot, + () => {}, + ).then(() => { + done = true; + }); + await vi.advanceTimersByTimeAsync(3000); + expect(done).toBe(false); + finish(); + await pending; + vi.useRealTimers(); +}); diff --git a/tests/relay/quota.test.ts b/tests/relay/quota.test.ts new file mode 100644 index 00000000..4de492c9 --- /dev/null +++ b/tests/relay/quota.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it } from 'vitest'; +import { createFileQuota } from '../../scripts/relay/quota.js'; +const dirs: string[] = []; +async function file() { + const dir = await mkdtemp(join(tmpdir(), 'oy-quota-')); + dirs.push(dir); + return join(dir, 'quota.json'); +} +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); +it('동시 요청과 재시작에도 분당 30회 상한을 유지한다', async () => { + const path = await file(); + let now = Date.UTC(2026, 8, 15); + const take = await createFileQuota(path, () => now); + expect( + (await Promise.all(Array.from({ length: 35 }, () => take()))).filter(Boolean), + ).toHaveLength(30); + expect(await (await createFileQuota(path, () => now))()).toBe(false); + now += 60000; + expect(await take()).toBe(true); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(31); +}); +it('하루 3000회 이후 차단하고 다음 UTC 날짜에 재개한다', async () => { + const path = await file(); + let now = Date.UTC(2026, 8, 15); + await writeFile( + path, + JSON.stringify({ + day: Math.floor(now / 86400000), + minute: Math.floor(now / 60000), + dayCount: 2999, + minuteCount: 0, + }), + ); + const take = await createFileQuota(path, () => now); + expect(await take()).toBe(true); + now += 60000; + expect(await take()).toBe(false); + now += 86400000; + expect(await take()).toBe(true); +}); +it('손상된 원장이나 저장 실패 시 조회를 허용하지 않는다', async () => { + const path = await file(); + await writeFile(path, 'invalid'); + await expect(createFileQuota(path)).rejects.toThrow(); + await writeFile(path, '{}'); + await expect(createFileQuota(path)).rejects.toThrow(); + await rm(path); + const take = await createFileQuota(path); + await rm(join(path, '..'), { recursive: true }); + await expect(take()).rejects.toThrow(); +}); +it('시간이 뒤로 가도 예산을 복원하지 않는다', async () => { + const path = await file(); + let now = Date.UTC(2026, 8, 15); + const take = await createFileQuota(path, () => now); + expect(await take()).toBe(true); + now -= 86400000; + expect(await take()).toBe(true); + expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(2); +}); diff --git a/tests/relay/supervisor.test.ts b/tests/relay/supervisor.test.ts new file mode 100644 index 00000000..fe8f3f24 --- /dev/null +++ b/tests/relay/supervisor.test.ts @@ -0,0 +1,66 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +const mocks = vi.hoisted(() => ({ fork: vi.fn(), connect: vi.fn(), snapshot: vi.fn() })); +vi.mock('node:child_process', () => ({ fork: mocks.fork })); +vi.mock('playwright', () => ({ chromium: { connect: mocks.connect } })); +vi.mock('../../scripts/relay/ownership.js', async (importOriginal) => ({ + ...(await importOriginal()), + processSnapshot: mocks.snapshot, +})); +import { launchGuardedBrowser } from '../../scripts/relay/supervisor.js'; +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); +}); +const root = { pid: 123, ppid: 1, pgid: 123, rss: 10, birth: 'start' }; +function fixture() { + const child = Object.assign(new EventEmitter(), { connected: true, send: vi.fn() }); + mocks.fork.mockReturnValue(child); + mocks.connect.mockResolvedValue({ newContext: vi.fn() }); + mocks.snapshot.mockResolvedValue([root, { ...root, pid: 124, rss: 20 }]); + child.send.mockImplementation(() => queueMicrotask(() => child.emit('exit', 0))); + return child; +} +it('준비된 소유 브라우저에 연결하고 그룹 RSS 및 정상 종료를 제공한다', async () => { + const child = fixture(); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'other' }); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + const owner = await pending; + expect(await owner.rss()).toBe(30); + expect(owner.pid).toBe(123); + await owner.close(); + expect(child.send).toHaveBeenCalledWith('close'); + await owner.close(); + expect(child.send).toHaveBeenCalledTimes(1); +}); +it('준비 전 감시 프로세스 종료는 후속 실행을 허용하지 않는다', async () => { + const child = fixture(); + const pending = launchGuardedBrowser('/tmp/test-owner'); + const rejected = expect(pending).rejects.toThrow('cleanup unconfirmed'); + child.emit('exit', 1); + await rejected; + expect(mocks.connect).not.toHaveBeenCalled(); +}); +it('브라우저 연결 실패도 소유 감시 프로세스를 종료한다', async () => { + const child = fixture(); + mocks.connect.mockRejectedValue(new Error('connect failed')); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + await expect(pending).rejects.toThrow('connect failed'); + expect(child.send).toHaveBeenCalledWith('close'); +}); +it('감시 프로세스 종료가 멈추면 확인 실패로 종료한다', async () => { + vi.useFakeTimers(); + const child = fixture(); + child.send.mockImplementation(() => {}); + const pending = launchGuardedBrowser('/tmp/test-owner'); + child.emit('message', { type: 'ready', endpoint: 'ws://localhost', root }); + const owner = await pending; + child.connected = false; + const closing = owner.close(); + const rejected = expect(closing).rejects.toThrow('watchdog'); + await vi.advanceTimersByTimeAsync(9000); + await rejected; + expect(child.send).not.toHaveBeenCalled(); +}); diff --git a/tests/scripts/relay-install-security.test.ts b/tests/scripts/relay-install-security.test.ts new file mode 100644 index 00000000..c53c169c --- /dev/null +++ b/tests/scripts/relay-install-security.test.ts @@ -0,0 +1,46 @@ +import { execFileSync } from 'node:child_process'; +import { expect, it } from 'vitest'; +it('설치 원본의 외부·끊어진 링크를 거절하고 내부 링크만 허용한다',()=>{ + const output=execFileSync(process.platform==='darwin'?'/usr/bin/python3':'python3',['-c',` +import runpy,tempfile +from pathlib import Path +m=runpy.run_path('scripts/relay/install-macos.py') +with tempfile.TemporaryDirectory() as d: + root=Path(d).resolve()/'root';root.mkdir();(root/'file').write_text('safe') + (root/'link').symlink_to('file');m['validate_tree'](root) + (root/'link').unlink();(root/'link').symlink_to('../outside') + try:m['validate_tree'](root);raise AssertionError('escaping link accepted') + except ValueError:pass + (root/'link').unlink();(root/'link').symlink_to(root/'file') + try:m['validate_tree'](root);raise AssertionError('absolute link accepted') + except ValueError:pass +print('ok') +`],{encoding:'utf8'}); + expect(output.trim()).toBe('ok'); +}); +it('설치 후속 단계가 실패하면 새 런타임과 비밀 파일을 회수한다',()=>{ + const output=execFileSync(process.platform==='darwin'?'/usr/bin/python3':'python3',['-c',` +import importlib.util,tempfile,os,types +from pathlib import Path +spec=importlib.util.spec_from_file_location('installer','scripts/relay/install-macos.py');m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +with tempfile.TemporaryDirectory() as d: + root=Path(d).resolve();source=root/'source';source.mkdir();browser=root/'browser';browser.mkdir();home=root/'home';home.mkdir() + for n in ['scripts/relay','src','node_modules']:(source/n).mkdir(parents=True) + for n in ['package.json','package-lock.json','tsconfig.json']:(source/n).write_text('{}') + user=types.SimpleNamespace(pw_uid=os.getuid(),pw_gid=os.getgid()) + original=m.write_owned; calls=0 + def fail(path,content,uid,gid,mode): + global calls + calls+=1 + if calls==2:raise OSError('test failure') + return original(path,content,uid,gid,mode) + m.write_owned=fail + try:m.install_files(source,browser,root/'runtime',home,user,{'relayToken':'test','tunnelToken':'test'},Path('/node'),Path('/cf'));raise AssertionError('failure expected') + except OSError:pass + assert not (root/'runtime').exists() + assert not (home/'Library/Application Support/DaisoRelay/relay.env').exists() + assert not list(root.glob('DaisoRelay.staging-*')) +print('ok') +`],{encoding:'utf8'}); + expect(output.trim()).toBe('ok'); +}); diff --git a/tests/scripts/relay-install.test.ts b/tests/scripts/relay-install.test.ts new file mode 100644 index 00000000..181a4f5d --- /dev/null +++ b/tests/scripts/relay-install.test.ts @@ -0,0 +1,21 @@ +import { execFileSync } from 'node:child_process'; +import { expect, it } from 'vitest'; +it('macOS 설치 설정은 GUI 전용 계정과 제한된 Node 메모리·재시작을 사용한다',()=>{ + const output=execFileSync(process.platform === 'darwin' ? '/usr/bin/python3' : 'python3',['-c',` +import runpy,json +from pathlib import Path +module=runpy.run_path('scripts/relay/install-macos.py') +p=module['agent_plist'](Path('/runtime'),Path('/Users/daisorelay'),Path('/node')) +t=module['tunnel_plist'](Path('/Users/daisorelay'),Path('/cloudflared')) +assert '--max-old-space-size=256' in p['ProgramArguments'] +assert p['LimitLoadToSessionType']=='Aqua' +assert p['KeepAlive']=={'SuccessfulExit':False} +assert p['ThrottleInterval']==60 and not p['AbandonProcessGroup'] +assert p['StandardErrorPath']=='/dev/null' +assert '--token-file' in t['ProgramArguments'] +assert '--token' not in t['ProgramArguments'] +assert 'UserName' not in p +print('ok') +`],{encoding:'utf8'}); + expect(output.trim()).toBe('ok'); +}); diff --git a/tests/services/oliveyoung/relay-redirect.test.ts b/tests/services/oliveyoung/relay-redirect.test.ts new file mode 100644 index 00000000..197c9914 --- /dev/null +++ b/tests/services/oliveyoung/relay-redirect.test.ts @@ -0,0 +1,19 @@ +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { expect, it } from 'vitest'; +import { requestOliveyoung } from '../../../src/services/oliveyoung/transport.js'; +it('리다이렉트된 다른 서버에 Access 비밀을 전달하지 않는다', async () => { + let received=0; + const target=createServer((_req,res)=>{received++;res.writeHead(200,{'Content-Type':'application/json'});res.end('{"status":"SUCCESS"}');}); + target.listen(0,'127.0.0.1');await once(target,'listening'); + const targetPort=(target.address() as {port:number}).port; + const relay=createServer((_req,res)=>{res.writeHead(302,{Location:`http://127.0.0.1:${targetPort}/leak`});res.end();}); + relay.listen(0,'127.0.0.1');await once(relay,'listening'); + try{ + await expect(requestOliveyoung('/p',{}, {relayUrl:`http://127.0.0.1:${(relay.address() as {port:number}).port}`,relayToken:'test-only',accessClientId:'test-id',accessClientSecret:'test-only-secret'})).rejects.toThrow('릴레이 요청 실패'); + expect(received).toBe(0); + }finally{ + target.closeAllConnections();relay.closeAllConnections(); + await Promise.all([new Promise((resolve)=>target.close(()=>resolve())),new Promise((resolve)=>relay.close(()=>resolve()))]); + } +}); diff --git a/tests/services/oliveyoung/transport.test.ts b/tests/services/oliveyoung/transport.test.ts index 3897fcc9..448408ea 100644 --- a/tests/services/oliveyoung/transport.test.ts +++ b/tests/services/oliveyoung/transport.test.ts @@ -3,41 +3,119 @@ import { fetchOliveyoungStores } from '../../../src/services/oliveyoung/client.j const params = { latitude: 37.5, longitude: 127, pageIdx: 1, searchWords: '' }; afterEach(() => vi.unstubAllGlobals()); it('키 없이 공식 API JSON을 직접 읽고 Zyte를 호출하지 않는다', async () => { - const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 7 } })); + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 7 } })); vi.stubGlobal('fetch', fetch); expect((await fetchOliveyoungStores(params)).totalCount).toBe(7); - expect(fetch.mock.calls[0][0]).toBe('https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store'); + expect(fetch.mock.calls[0][0]).toBe( + 'https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store', + ); }); it('신뢰한 옵션의 릴레이를 우선 사용하고 토큰을 전달한다', async () => { - const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 8 } })); + const fetch = vi + .fn() + .mockResolvedValue(Response.json({ status: 'SUCCESS', data: { totalCount: 8 } })); vi.stubGlobal('fetch', fetch); - expect((await fetchOliveyoungStores(params, { relayUrl: 'https://relay.example', relayToken: 'test' })).totalCount).toBe(8); - expect(fetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/find-store', expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test' }) })); + expect( + (await fetchOliveyoungStores(params, { relayUrl: 'https://relay.example', relayToken: 'test' })) + .totalCount, + ).toBe(8); + expect(fetch).toHaveBeenCalledWith( + 'https://relay.example/v1/oliveyoung/find-store', + expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test' }) }), + ); }); it('HTTP201의 SUCCESS 본문도 정상 응답으로 처리하지 않는다', async () => { - vi.stubGlobal('fetch', vi.fn().mockResolvedValue(Response.json({status:'SUCCESS'}, {status:201}))); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS' }, { status: 201 })), + ); const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); - await expect(requestOliveyoung('/oystore/api/stock/product-search-v3', {})).rejects.toThrow('직접 요청 실패'); + await expect(requestOliveyoung('/oystore/api/stock/product-search-v3', {})).rejects.toThrow( + '직접 요청 실패', + ); }); it('릴레이 토큰과 주소 설정 오류는 네트워크 호출 전에 거절한다', async () => { - const fetch = vi.fn(); vi.stubGlobal('fetch', fetch); + const fetch = vi.fn(); + vi.stubGlobal('fetch', fetch); const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); - for (const relayUrl of ['bad url', 'http://remote.example', 'https://user:pass@relay.example', 'https://relay.example?token=x', 'https://relay.example#x', 'ftp://localhost']) { - await expect(requestOliveyoung('/p', {}, {relayUrl,relayToken:'test'})).rejects.toThrow('릴레이 URL'); + for (const relayUrl of [ + 'bad url', + 'http://remote.example', + 'https://user:pass@relay.example', + 'https://relay.example?token=x', + 'https://relay.example#x', + 'ftp://localhost', + ]) { + await expect(requestOliveyoung('/p', {}, { relayUrl, relayToken: 'test' })).rejects.toThrow( + '릴레이 URL', + ); } - await expect(requestOliveyoung('/p', {}, {relayUrl:'https://relay.example'})).rejects.toThrow('OY_RELAY_TOKEN'); + await expect(requestOliveyoung('/p', {}, { relayUrl: 'https://relay.example' })).rejects.toThrow( + 'OY_RELAY_TOKEN', + ); expect(fetch).not.toHaveBeenCalled(); }); it('로컬 릴레이 요청의 실패는 비밀 없이 전달하고 재시도하지 않는다', async () => { - const fetch = vi.fn().mockResolvedValue(new Response('secret', {status:502})); vi.stubGlobal('fetch', fetch); + const fetch = vi.fn().mockResolvedValue(new Response('secret', { status: 502 })); + vi.stubGlobal('fetch', fetch); const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); - await expect(requestOliveyoung('/p', {}, {relayUrl:'http://127.0.0.1:4319/',relayToken:'secret'})).rejects.toThrow('올리브영 브라우저 릴레이 요청 실패'); + await expect( + requestOliveyoung('/p', {}, { relayUrl: 'http://127.0.0.1:4319/', relayToken: 'secret' }), + ).rejects.toThrow('올리브영 브라우저 릴레이 요청 실패'); expect(fetch).toHaveBeenCalledTimes(1); }); it('도구의 사용자 입력으로 신뢰한 릴레이 설정을 덮어쓸 수 없다', async () => { - const fetch = vi.fn().mockResolvedValue(Response.json({status:'SUCCESS',data:{}})); vi.stubGlobal('fetch',fetch); - const { createSearchProductsTool } = await import('../../../src/services/oliveyoung/tools/searchProducts.js'); - const tool = createSearchProductsTool(undefined, {relayUrl:'https://trusted.example',relayToken:'trusted'}); - await tool.handler({keyword:'팩',relayUrl:'https://evil.example',relayToken:'evil'}); - expect(fetch).toHaveBeenCalledWith('https://trusted.example/v1/oliveyoung/product-search-v3',expect.objectContaining({headers:expect.objectContaining({Authorization:'Bearer trusted'})})); + const fetch = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'SUCCESS', data: {} })); + vi.stubGlobal('fetch', fetch); + const { createSearchProductsTool } = + await import('../../../src/services/oliveyoung/tools/searchProducts.js'); + const tool = createSearchProductsTool(undefined, { + relayUrl: 'https://trusted.example', + relayToken: 'trusted', + }); + await tool.handler({ keyword: '팩', relayUrl: 'https://evil.example', relayToken: 'evil' }); + expect(fetch).toHaveBeenCalledWith( + 'https://trusted.example/v1/oliveyoung/product-search-v3', + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer trusted' }), + }), + ); +}); +it('Access 서비스 자격증명을 중계에만 전달한다', async () => { + const fetch = vi + .fn() + .mockImplementation(async () => Response.json({ status: 'SUCCESS', data: {} })); + vi.stubGlobal('fetch', fetch); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + const options = { + relayUrl: 'https://trusted.example', + relayToken: 'relay', + accessClientId: 'id', + accessClientSecret: 'secret', + }; + await requestOliveyoung('/p', {}, options); + expect(fetch.mock.calls[0][1].headers).toMatchObject({ + 'CF-Access-Client-Id': 'id', + 'CF-Access-Client-Secret': 'secret', + }); + await requestOliveyoung('/p', {}, { accessClientId: 'id', accessClientSecret: 'secret' }); + expect(fetch.mock.calls[1][1].headers).not.toHaveProperty('CF-Access-Client-Secret'); + for (const partial of [ + { accessClientId: 'id' }, + { accessClientSecret: 'secret' }, + { accessClientId: ' ', accessClientSecret: 'secret' }, + ]) { + await expect( + requestOliveyoung( + '/p', + {}, + { ...options, accessClientId: undefined, accessClientSecret: undefined, ...partial }, + ), + ).rejects.toThrow('Access'); + } + expect(fetch).toHaveBeenCalledTimes(2); }); diff --git a/vitest.config.ts b/vitest.config.ts index 6a23fe59..7b9d8a7a 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/oliveyoung.ts', 'scripts/relay/browser.ts'], + include: ['src/**/*.ts', 'scripts/relay/oliveyoung.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/browser-environment.ts'], // 커버리지 제외 파일 exclude: [