diff --git a/.github/workflows/external-smoke.yml b/.github/workflows/external-smoke.yml index 34bf6987..5f68e1e7 100644 --- a/.github/workflows/external-smoke.yml +++ b/.github/workflows/external-smoke.yml @@ -36,8 +36,8 @@ jobs: - name: Run external smoke sequentially env: - CLI_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx - MCP_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 opinet + CLI_SMOKE_SERVICES: daiso daiso_jp cu gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx + MCP_SMOKE_SERVICES: daiso daiso_jp cu gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90 run: | set +e diff --git a/docs/macos-ops-runbook.md b/docs/macos-ops-runbook.md new file mode 100644 index 00000000..9b3e0457 --- /dev/null +++ b/docs/macos-ops-runbook.md @@ -0,0 +1,59 @@ +# 현재 사용자 Mac 운영 보조 도구 + +`install-macos.py`의 전용 표준 계정 설치는 그대로 유지한다. `macos-ops.py`는 현재 GUI 사용자에게 숨김 도우미와 로컬 감시기를 추가한다. 현재 사용자 소유의 중계 런타임, 브라우저, Node 의존성, 터널과 로그인 세션은 먼저 준비해야 한다. 이 도구는 런타임 소유권을 검사하므로 기존 전용 계정 설치기의 root 소유 런타임을 현재 사용자 모드에서 직접 지정할 수 없다. 전용 계정 설치는 별도 운영 방식으로 유지한다. [전용 계정 Olive Young 설치기](../scripts/relay/install-macos.py)와 [디트릭스 중계 준비 절차](dtryx-relay.md)를 먼저 따른다. 이 보조 도구는 전체 중계 설치기가 아니다. 새 Mac에서 인증 자료는 운영자가 직접 제공하며 이 도구는 인증·환경·호출 원장 파일을 만들거나 교체하지 않는다. + +## 계획과 설치 + +저장소 루트에서 실행한다. 기본 상태 디렉터리는 현재 사용자의 `~/Library/Application Support/DaisoRelay`, 기본 런타임은 그 아래 `runtime`이다. 기존 중계를 재설치하거나 Node 프로세스를 실행하지 않는다. + +```sh +/usr/bin/python3 scripts/relay/macos-ops.py plan +/usr/bin/python3 scripts/relay/macos-ops.py install +/usr/bin/python3 scripts/relay/macos-ops.py install --notify-local --apply +``` + +앞의 두 명령은 파일을 쓰지 않는다. `--state-base /absolute/private/state`와 `--runtime /absolute/runtime`으로 위치를 바꿀 수 있다. 심볼릭 링크 경로, 다른 소유자, 쓰기 가능한 공개 파일은 거부한다. 상태 디렉터리와 환경 파일은 각각 0700, 0600이어야 한다. `/usr/bin/python3`와 `/usr/bin/swiftc`를 사용한다. 관리자·root 실행은 거부한다. + +설치는 소스 해시별 `ops-helpers/`에 실행 파일과 소스를 보관하고 두 새 plist만 교체한다. 기존 plist는 개인 권한으로 백업하며 관리 표식이 없는 동일 이름 파일은 거부한다. 기존 helper 버전은 실행 중인 프로세스를 보호하도록 남긴다. 재사용하는 숨김 실행 파일에 실행 권한이 없으면 설치를 거부한다. 실행 중인 작업을 자동으로 내리거나 새 작업을 자동으로 올리지 않는다. + +## GUI 세션 활성화 + +실제 경로를 설치 계획에 맞춘다. 이전 수동 숨김 작업 `page.aka.daiso-oliveyoung-hide`를 사용 중이면 검토 후 그 작업만 먼저 `launchctl bootout`한다. 중계 브라우저는 종료하지 않는다. 새 숨김 작업은 `page.aka.daiso-ops-hide`, 감시기는 `page.aka.daiso-ops-monitor`이다. + +```sh +launchctl bootout "gui/$(id -u)/page.aka.daiso-oliveyoung-hide" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/page.aka.daiso-ops-hide.plist" +launchctl bootstrap "gui/$(id -u)" "$HOME/Library/LaunchAgents/page.aka.daiso-ops-monitor.plist" +``` + +업데이트할 때는 새 plist 설치 후 이 두 보조 작업만 `bootout`하고 다시 `bootstrap`한다. 중계·터널 작업의 종료나 재시작은 포함하지 않는다. 숨김 도우미는 비공개 `state/browser-owner.json`의 PID·부모 PID·생성 시각과 실제 프로세스 UID·실행 파일이 모두 맞을 때만 숨긴다. 이미 숨겨진 앱도 소유권을 검증한다. 사용자 Chrome이나 소유권이 불확실한 프로세스에는 요청하지 않는다. + +## 감시와 상태 확인 + +```sh +/usr/bin/python3 scripts/relay/macos-ops.py monitor +/usr/bin/python3 scripts/relay/macos-ops.py status +``` + +감시 LaunchAgent는 로그인 시와 매 60초 실행한다. `relay.env`의 `OY_RELAY_TOKEN`, `dtryx.env`의 `DTRYX_RELAY_TOKEN`·`CONVENIENCE_RELAY_TOKEN`을 개인 파일에서 읽는다. 셸을 실행하거나 비밀값을 출력하지 않는다. 선택 환경 변수의 `KEY=` 빈 값은 허용하고 인증 토큰은 별도로 비어 있지 않은지 확인한다. 환경 파일의 포트·URL 값으로 감시 대상을 바꾸지 않는다. `GS25_AUTH_SESSION_FILE`을 지정한 경우 편의점 health의 인증 상태·로그인 필요 여부가 없거나 유효하지 않으면 `authentication-unobservable` 실패로 기록한다. `ready`는 access·refresh 만료 시각이 모두 미래인 경우에만 관측 가능한 정상 인증으로 판단한다. GS 세션을 구성하지 않은 중계는 인증 메타데이터 없이도 지원한다. 세션 파일 경로는 출력하지 않는다. + +- Olive Young: `127.0.0.1:4319/health` +- 디트릭스: `127.0.0.1:4320/v1/dtryx/health` +- CGV: `127.0.0.1:4320/v1/cgv/health` +- 편의점: `127.0.0.1:4320/v1/convenience/health` +- 선택 터널 준비 상태: `127.0.0.1:20242/ready` + +터널 포트는 설치·monitor 명령에 `--tunnel-port 20242`처럼 지정한다. 1024~65535만 허용하며 0은 터널 검사 생략이다. 터널의 loopback metrics 준비 엔드포인트는 별도로 구성해야 한다. `/ready` 성공은 로컬 터널 준비 상태만 의미하며 외부 공개 경로의 성공을 보장하지 않는다. + +프록시를 사용하지 않고 리디렉션을 거부하며, 3초 연결 제한, 연결 후 헤더·본문 전체 4초 제한과 64KiB 본문 제한을 적용한다. `ops-status.json`은 0600으로 원자적으로 교체한다. 검사별 실패 여부, 연속 실패 횟수, 첫 실패 시각, GS 인증 상태·로그인 필요 여부·만료 시각·재시도 시각, 허용한 로그 오류·쿼터 숫자만 보존한다. 자체 쿼터의 `blockedBy`는 `null`, `minute`, `daily`만 허용하며 `minute`·`daily`이면 `reason: own-quota` 실패로 표시한다. 이는 원본 429와 별개인 중계 자체 한도이며 재고 0으로 바꾸지 않는다. status는 마지막 검사에서 180초가 넘거나 미래 시각이면 `stale: true`를 표시한다. 감시가 실행되지 않으면 파일은 자동 갱신되지 않는다. + +`authentication.loginRequired: true`이면 [GS25 정상 로그인 복구 절차](gs25-auth-recovery-runbook.md)에 따라 사용자가 로그인한다. `retry_wait`는 정상 갱신 실패 후 대기이며 로그인 필요와 구분한다. `--notify-local`을 설치 또는 monitor에 지정하면 로그인 필요가 확인될 때 고정 문구의 macOS 로컬 알림을 요청한다. 로그인 필요로 전환한 첫 검사와 이후 6시간 간격으로만 요청하며, 실패한 요청도 같은 간격으로 제한한다. 상태의 `notification.state`는 `accepted`(osascript 요청 수락), `failed`, `disabled`, `not-required`를 구분한다. OS 알림 권한·집중 모드에 따라 보이지 않을 수 있으며 수락은 실제 열람을 의미하지 않는다. 외부 네트워크 메시지나 직접 대화 푸시는 지원하지 않는다. 지속 상태는 파일과 status에서 확인한다. 기존 launchd가 죽은 중계를 복구하고 Olive Young watchdog이 실패한 lifecycle을 복구한다. 감시기는 프로세스 종료·브라우저 재시작·쿠키 초기화·원본 재고 요청을 하지 않는다. + +## 해제 + +```sh +launchctl bootout "gui/$(id -u)/page.aka.daiso-ops-hide" +launchctl bootout "gui/$(id -u)/page.aka.daiso-ops-monitor" +``` + +그 뒤 두 `page.aka.daiso-ops-*.plist`와 필요 없는 해당 plist 백업, `ops-helpers`, `ops-status.json`만 검토하여 삭제한다. 중계·터널 plist와 `relay.env`, `dtryx.env`, `state`, 인증·쿼터 원장은 보존한다. 기존 수동 숨김 도우미로 되돌리려면 보존한 기존 plist를 다시 bootstrap한다. 재부팅 후 실동작은 별도로 확인한다. diff --git a/docs/relay-observability.md b/docs/relay-observability.md index e85e9ff6..309d2eb1 100644 --- a/docs/relay-observability.md +++ b/docs/relay-observability.md @@ -77,3 +77,11 @@ CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확 무료 릴레이 요청의 기본 제한 시간은 대기와 재시도를 합쳐 60초다. 직접 조회는 기존 15초이며 명시적인 timeout은 우선한다. 릴레이 큐는 최대 30초 대기를 허용한다. 브라우저 교체가 22~23초 걸릴 때 Worker가 먼저 연결을 끊던 문제를 줄이기 위한 조정이며, 새 브라우저나 탭을 병렬로 만들지 않는다. `queue/canceled`는 클라이언트 취소, `queue/expired`는 큐 대기 초과로 구분한다. 따라서 예전 expired 수치와 단순 비교하면 안 된다. Zyte의 키·계정 상태와 관계없이 유료 경로는 계속 차단한다. 정상 조회는 직접 연결과 무료 릴레이로 처리하며, 릴레이 중단·전역 한도 소진·원본 차단까지 무조건 성공으로 바꾸지는 않는다. + +## Mac 상시 감시와 인증 준비 + +현재 사용자 환경의 설치·업데이트·해제는 [Mac 운영 보조 도구](macos-ops-runbook.md)를 따른다. 분당 로컬 감시는 실제 상품·재고 API를 추가 호출하지 않고 인증된 loopback health와 선택한 터널 ready를 확인한다. `ops-status.json`이 3분 넘게 오래되면 정상으로 판단하지 않는다. 이 파일의 정상은 공개 API 실재고 성공을 대신하지 않으므로 정기점검은 기존 캐시 우회 검사와 실제 CLI/MCP 검증도 유지한다. + +GS25 세션은 재고 요청이 없어도 중계 시작 시와 분당 준비한다. access 만료 5분 전에는 정상 갱신 경로를 이용하며, 실제 요청과 자동 준비가 중복 갱신과 실패 대기를 공유한다. 편의점 인증 health의 `authentication`은 토큰 대신 상태·만료 시각·로그인 필요 여부·재시도 시각만 반환한다. `login_required`는 사용자 입력이 필요하고 `retry_wait`는 일시적인 갱신 실패 대기다. 전자는 확인 즉시 사용자에게 알리며 과거 로그인 대기 기록만으로 현재 복구를 생략하지 않는다. + +공개 MCP 외부 스모크는 CLI와 같은 12개 서비스를 검사한다. 소매 재고는 상품 존재뿐 아니라 실제 매장 확인과 알려진 수량을 요구하며 정상 수량0과 미확인을 구분한다. 일본 다이소 상세는 다른 상품도 원본 진입을 직렬화하여 첫429 이후 공유 대기를 우회하지 못한다. 외부429와 자체 분당·일일 한도는 계속 구분한다. 메가박스의 기존 상세 캐시·중복 요청 공유·취소 기한·지연 경고 기준은 유지하며 원본 지연을 해결했다고 간주하지 않는다. diff --git a/docs/superpowers/plans/2026-10-25-operational-reliability.md b/docs/superpowers/plans/2026-10-25-operational-reliability.md new file mode 100644 index 00000000..bd306bd2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-25-operational-reliability.md @@ -0,0 +1,48 @@ +# 운영 안정성 구현 계획 + +> 구현 스킬: superpowers:subagent-driven-development. 각 작업은 회귀 검증과 독립 검토를 거친다. + +**Goal:** 실제 사용자 호출 검사와 Mac 운영 재현성을 개선하고 인증·원본 제한을 지속적으로 관리한다. +**Architecture:** 공개 도구 계약과 기존 중계를 유지하며 운영 모듈과 설치 보조 기능만 확장한다. +**Tech Stack:** TypeScript, Vitest, Python 표준 라이브러리, AppKit Swift, launchd. + +## 1. 공개 MCP 서비스 검사 확대 + +- 수정: scripts/ops/mcp-smoke.ts, scripts/ops/mcp-smoke-validation.ts, .github/workflows/external-smoke.yml. +- 검사: tests/scripts/mcp-smoke*.test.ts, cinema-smoke.test.ts. +- [x] 빠진 서비스의 실제 공개 호출과 상품·매장 재고 증거를 거절/수용하는 회귀를 먼저 작성한다. +- [x] `npx vitest run tests/scripts`로 실패 재현 후 최소 구현한다. +- [x] 모든 서비스가 워크플로에 포함되고 도구10개·카탈로그·기존 상품명 조회가 유지되는지 확인한다. + +## 2. GS 인증 상시 준비·안전한 상태 + +- 수정: scripts/relay/gs25-session.ts, dtryx-start.ts. +- 검사: tests/relay/gs25-session.test.ts 및 신규 상태·주기 준비 회귀. +- [x] stock fetch 없이 prepare/status를 호출하고 만료·정상갱신·일시실패 backoff·중복갱신을 검증한다. +- [x] 실패 전용 테스트를 실행한 후 transport에 준비·상태 메서드를 추가한다. +- [x] 분당 준비와 health 노출을 연결하며 shutdown 뒤 추가 갱신을 막는다. + +## 3. Mac 사용자 범위 운영 도구 + +- 생성: scripts/relay/macos-ops.py, scripts/relay/hide-oliveyoung.swift. +- 검사: tests/scripts/macos-ops.test.ts 및 실제 macOS 빌드·소유검증. +- [x] plan/status/apply/watch 설정의 임의 URL·인증 출력·기존파일 덮어쓰기를 막는 테스트를 작성한다. +- [x] 전용 숨김 작업과 loopback 상태 스냅샷 감시를 등록한다. 감시는 인증된 로컬 health·GS 인증 상태·설정된 터널 ready만 읽는다. +- [x] 실패를 재고0이나 정상으로 바꾸지 않고 private 상태 파일에 기록한다. 인증·원장은 변경하지 않는다. + +## 4. 원본 진입 제한 + +- 수정: scripts/relay/daiso-jp.ts. +- 검사: tests/relay/daiso-jp.test.ts. +- [x] 다른 상품 동시 요청에서 첫429 후 원본 추가 호출이 없어야 하는 회귀를 작성한다. +- [x] 기존 HTTP 계층의 동일 요청 공유·취소를 유지하며 원본 호출만 직렬화한다. +- [x] deadline·취소·성공 후 제한 해제·Retry-After와 자체quota 구분을 검증한다. + +## 5. 통합·배포 + +- [ ] `npm run check`, `npm run test:coverage`, `npm run build` 통과. +- [ ] 기능 요구 검토 후 코드 품질 검토, 지적 수정 및 재검증. +- [ ] 논리 단위 커밋과 PR, GitHub 검사 성공 후 병합·배포. +- [ ] Mac 유휴·정상 종료·원본 백업·SHA 대조 후 변경 파일 반영. +- [ ] 인증된 fresh OY/GS와 실제 MCP/CLI, 감시 상태·숨김·전체 routes 신선도 확인. +- [ ] PROJECT.md에 결과와 실제 남은 문제를 기록한다. diff --git a/docs/superpowers/specs/2026-10-25-operational-reliability.md b/docs/superpowers/specs/2026-10-25-operational-reliability.md new file mode 100644 index 00000000..9b69c2b3 --- /dev/null +++ b/docs/superpowers/specs/2026-10-25-operational-reliability.md @@ -0,0 +1,10 @@ +# 운영 검사와 Mac 중계 재현성 + +사용자가 승인한 네 가지 개선을 기존 운영 구조 안에서 수행한다. 공개 MCP 도구는 정확히 10개이며 구형 호출과 JSON/HTML API 호환성을 유지한다. + +1. 실제 공개 MCP 호출의 서비스 범위를 CLI와 일치시키고, 재고는 상품 존재만이 아니라 실제 매장 확인 여부를 검증한다. 기존 카탈로그와 상품명 재고 시나리오는 유지한다. 선택적 데이터 누락과 외부 실패를 숨기지 않는다. +2. GS 인증은 원본 재고 요청 없이 주기적으로 준비·정상 갱신하고 안전한 만료 시각·로그인 필요 상태를 인증된 health에 노출한다. 다른 중계와 터널은 loopback health 기반 감시로 장애 지속을 기록하고 프로세스가 죽은 경우 기존 launchd 복구를 활용한다. 불확실 소유 프로세스나 사용 중 프로세스를 추정 종료하지 않는다. 외부 알림은 이 대화의 사용자 개입 요청을 통해 처리하며 새로운 외부 메시지 경로는 만들지 않는다. +3. 이 Mac의 운영 설정을 재현할 수 있는 사용자 범위 관리 도구를 추가한다. 기존 전용 계정 설치기를 보존하며 자동 숨김 Swift 소스, 계획 출력, 상태 확인, 명시적 적용과 해제 방법을 제공한다. 기존 인증·환경·원장·터널 설정은 덮어쓰지 않는다. 새 Mac의 로그인 세션과 인증은 필요하다. +4. 기존 동일 요청 합치기·TTL 캐시를 유지한다. 일본 상세의 서로 다른 상품 동시 요청이 제한을 증폭하지 않도록 원본 진입을 직렬화하고 첫429 이후 대기를 모든 요청이 공유한다. 이미 대기 중인 요청도 원본 재시도 전에 제한을 확인한다. 메가박스 지연은 실제 기한 내 취소와 정확한 경고를 유지하며 검사 임계치를 완화하지 않는다. + +각 기능은 실패 회귀 재현 후 구현하고 4지표100% 커버리지·필수검사·독립리뷰를 통과한다. GitHub 병합과 배포 뒤 필요한 Mac 파일만 정상 종료 확인 후 교체하고 실제 캐시 우회·MCP·CLI 응답으로 검증한다. 원본 제한의 영구 해소나 재부팅 실검증을 수행하지 않고 주장하지 않는다. diff --git a/scripts/ops/mcp-smoke-validation.ts b/scripts/ops/mcp-smoke-validation.ts index 21c2052e..405ecfba 100644 --- a/scripts/ops/mcp-smoke-validation.ts +++ b/scripts/ops/mcp-smoke-validation.ts @@ -46,3 +46,122 @@ const catalog = z.object({ export function validateCatalogSnapshot(payload: Record): string | null { return catalog.safeParse(payload).success ? null : '카탈로그 상품과 기획전 응답이 없습니다'; } + +const storeIdentity = z.object({ storeCode: text, storeName: text }); +const gs25Inventory = z.object({ + itemCode: text, + inventory: z.object({ + count: count.positive(), + stores: z.array(storeIdentity.extend({ realStockQuantity: count })).min(1), + }), +}); +const sevenInventory = z.object({ + product: z.object({ itemCode: text, itemName: text }), + stockAvailable: z.literal(true), + inventory: z.object({ + count: count.positive(), + stores: z.array(storeIdentity.extend({ stockQuantity: count })).min(1), + }), +}); +const emartInventory = z.object({ + pluCd: text, + inventory: z.object({ + count: count.positive(), + stores: z.array(z.object({ bizNo: text, storeName: text, bizQty: count })).min(1), + }), +}); +const cuInventory = z.object({ + inventory: z.object({ + available: z.literal(true), + items: z.array(z.object({ itemCode: text, itemName: text })).min(1), + }), + nearbyStores: z.object({ + available: z.literal(true), + stockItemCode: text, + stockItemName: text, + count: count.positive(), + stores: z.array(storeIdentity.extend({ stock: count })).min(1), + }), +}); +const oliveyoungInventory = z.object({ + inventory: z.object({ + stockCheckedCount: count.positive(), + products: z + .array( + z.object({ + goodsNumber: text, + goodsName: text, + stockSource: z.literal('nearby_store'), + stockStatus: z.enum(['in_stock', 'out_of_stock', 'not_sold']), + storeInventory: z.object({ + stores: z + .array( + storeIdentity.extend({ + remainQuantity: count.nullish(), + o2oRemainQuantity: count.nullish(), + stockStatus: z.enum(['in_stock', 'out_of_stock', 'not_sold', 'unknown']), + }), + ) + .min(1) + .refine((stores) => + stores.some( + (store) => + store.stockStatus !== 'unknown' && + (typeof store.remainQuantity === 'number' || + typeof store.o2oRemainQuantity === 'number'), + ), + ), + }), + }), + ) + .min(1), + }), +}); +const retailSchemas = { + gs25: gs25Inventory, + seveneleven: sevenInventory, + emart24: emartInventory, + cu: cuInventory, + oliveyoung: oliveyoungInventory, +}; +export function validateRetailInventory( + service: keyof typeof retailSchemas, + payload: Record, +): string | null { + const parsed = retailSchemas[service].safeParse(payload); + if (!parsed.success) return '상품 코드와 매장별 실제 재고 수량이 없습니다'; + if ('nearbyStores' in parsed.data) { + const data = parsed.data; + return data.nearbyStores.count === data.nearbyStores.stores.length && + data.inventory.items.some((item) => item.itemCode === data.nearbyStores.stockItemCode) + ? null + : 'CU 선택 상품 또는 매장 수가 실제 재고와 다릅니다'; + } + const inventory = parsed.data.inventory; + if ('products' in inventory) { + return inventory.stockCheckedCount === inventory.products.length + ? null + : '올리브영 실제 재고 조회 상품 수가 다릅니다'; + } + return inventory.count === inventory.stores.length ? null : '실제 재고 매장 수가 다릅니다'; +} + +export function validateCinemaCollection( + payload: Record, + service: 'megabox' | 'lottecinema' | 'cgv' | 'dtryx', +): string | null { + const schema = + service === 'dtryx' + ? z.object({ movies: z.array(z.object({ movieCode: text, movieName: text })).min(1) }) + : z.object({ + theaters: z + .array( + z.object({ + [service === 'cgv' ? 'theaterCode' : 'theaterId']: text, + theaterName: text, + }), + ) + .min(1), + }); + return schema.safeParse(payload).success ? null : '실제 극장 또는 상영작 목록이 없습니다'; +} diff --git a/scripts/ops/mcp-smoke.ts b/scripts/ops/mcp-smoke.ts index 899f301d..fa49f92a 100644 --- a/scripts/ops/mcp-smoke.ts +++ b/scripts/ops/mcp-smoke.ts @@ -2,6 +2,8 @@ import { validateDaisoInventoryByName, validateCatalogPopwords, validateCatalogSnapshot, + validateRetailInventory, + validateCinemaCollection, } from './mcp-smoke-validation.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** @@ -13,7 +15,19 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/ import { DEFAULT_MCP_URL } from '../../src/cli/constants.js'; type WriteFn = (message: string) => void; -type SmokeService = 'daiso_jp' | 'daiso' | 'gs25' | 'seveneleven' | 'emart24' | 'opinet'; +type SmokeService = + | 'daiso_jp' + | 'daiso' + | 'gs25' + | 'seveneleven' + | 'emart24' + | 'opinet' + | 'cu' + | 'oliveyoung' + | 'megabox' + | 'lottecinema' + | 'cgv' + | 'dtryx'; interface ToolListResult { tools: Array<{ name: string }>; @@ -38,28 +52,12 @@ interface McpSmokeDeps { service?: string; } -export const MCP_SMOKE_TOOLS: Array<{ service: SmokeService; name: string }> = [ - { service: 'daiso', name: 'daiso_search_products' }, - { service: 'daiso', name: 'daiso_check_inventory' }, - { service: 'daiso', name: 'daiso_find_inventory_by_name' }, - { service: 'daiso_jp', name: 'daiso_jp_search_products' }, - { service: 'daiso_jp', name: 'daiso_jp_get_product' }, - { service: 'daiso_jp', name: 'daiso_jp_find_stores' }, - { service: 'gs25', name: 'gs25_search_products' }, - { service: 'seveneleven', name: 'seveneleven_search_products' }, - { service: 'seveneleven', name: 'seveneleven_get_search_popwords' }, - { service: 'seveneleven', name: 'seveneleven_get_catalog_snapshot' }, - { service: 'emart24', name: 'emart24_search_products' }, - { service: 'opinet', name: 'opinet_get_average_prices' }, -]; - -export const MCP_SMOKE_TOOL_NAMES = MCP_SMOKE_TOOLS.map((tool) => tool.name); - interface McpSmokeScenario { service: SmokeService; label: string; toolName: string; args: Record; + productCode?: 'itemCode' | 'pluCd'; validate: (payload: Record) => string | null; } @@ -97,11 +95,14 @@ function formatScenarioFailure( scenario: McpSmokeScenario, message: string, result?: ToolCallResult, + args: Record = scenario.args, ): string { + const group = COMPACT_CATALOG.find((g) => g.routes.some((r) => r.legacy === scenario.toolName))!; return [ `MCP smoke 실패: ${scenario.label} - ${message}`, - `tool=${scenario.toolName}`, - `args=${JSON.stringify(scenario.args)}`, + `tool=${group.name}`, + `scenario=${scenario.toolName}`, + `args=${JSON.stringify(args)}`, `responseExcerpt=${createResponseExcerpt(result)}`, ].join(' '); } @@ -220,8 +221,52 @@ export const MCP_SMOKE_SCENARIOS: McpSmokeScenario[] = [ args: { timeoutMs: 10000 }, validate: expectField('provider', 'opinet'), }, + ...(['gs25', 'seveneleven', 'emart24', 'cu', 'oliveyoung'] as const).map((service) => ({ + service, + label: `${service} 실제 매장 재고`, + toolName: `${service}_check_inventory`, + args: { + keyword: service === 'gs25' ? '콜라' : service === 'oliveyoung' ? '선크림' : '커피', + storeLimit: 1, + timeoutMs: 20000, + ...(service === 'seveneleven' || service === 'emart24' ? { storeKeyword: '강남' } : {}), + ...(service === 'gs25' || service === 'cu' || service === 'oliveyoung' + ? { latitude: 37.4979, longitude: 127.0276 } + : {}), + ...(service === 'cu' ? { size: 1 } : {}), + ...(service === 'oliveyoung' ? { size: 1, stockCheckLimit: 1 } : {}), + }, + ...(service === 'gs25' ? { productCode: 'itemCode' as const } : {}), + ...(service === 'emart24' ? { productCode: 'pluCd' as const } : {}), + validate: (payload: Record) => validateRetailInventory(service, payload), + })), + ...(['megabox', 'lottecinema', 'cgv'] as const).map((service) => ({ + service, + label: `${service} 극장 검색`, + toolName: service === 'cgv' ? 'cgv_find_theaters' : `${service}_find_nearby_theaters`, + args: { latitude: 37.4979, longitude: 127.0276, limit: 1, timeoutMs: 15000 }, + validate: (payload: Record) => validateCinemaCollection(payload, service), + })), + { + service: 'dtryx', + label: '디트릭스 상영작 조회', + toolName: 'dtryx_list_now_showing', + args: { cinemaCode: '000067', includePlayDates: false, timeoutMs: 15000 }, + validate: (payload) => validateCinemaCollection(payload, 'dtryx'), + }, ]; +// 이전 시나리오 ID는 호출 라우팅에만 사용하고 공개 도구 목록은 늘리지 않습니다. +export const MCP_SMOKE_TOOLS = [ + { service: 'daiso', name: 'daiso_search_products' }, + { service: 'daiso', name: 'daiso_check_inventory' }, + ...MCP_SMOKE_SCENARIOS.map((scenario) => ({ + service: scenario.service, + name: scenario.toolName, + })), +]; +export const MCP_SMOKE_TOOL_NAMES = MCP_SMOKE_TOOLS.map((tool) => tool.name); + function parseServiceArg(argv: string[]): string | undefined { const serviceIndex = argv.indexOf('--service'); if (serviceIndex >= 0) { @@ -277,36 +322,48 @@ export async function runMcpSmoke(deps: McpSmokeDeps = {}): Promise { return 1; } + const productSearches = new Map>(); for (const scenario of scenarios) { writeOut(`MCP smoke 호출: ${scenario.label}`); let result: ToolCallResult | undefined; + let args: Record = scenario.args; try { const group = COMPACT_CATALOG.find((group) => group.routes.some((route) => route.legacy === scenario.toolName), )!; const route = group.routes.find((route) => route.legacy === scenario.toolName)!; + args = { + ...scenario.args, + ...(route.service ? { service: route.service } : {}), + ...(route.operation ? { operation: route.operation } : {}), + }; + if (scenario.productCode) { + const products = productSearches.get(scenario.service)?.products; + const first = Array.isArray(products) ? products[0] : undefined; + const code = isRecord(first) ? first[scenario.productCode] : undefined; + if (typeof code !== 'string' || !code.trim()) + throw new Error('검색한 상품 코드가 없습니다'); + args[scenario.productCode] = code; + } result = await client.callTool({ name: group.name, - arguments: { - ...scenario.args, - ...(route.service ? { service: route.service } : {}), - ...(route.operation ? { operation: route.operation } : {}), - }, + arguments: args, }); const payload = parseToolPayload(result); const validationError = scenario.validate(payload); + if (group.name === 'search_products') productSearches.set(scenario.service, payload); if (validationError) { - writeErr(formatScenarioFailure(scenario, validationError, result)); + writeErr(formatScenarioFailure(scenario, validationError, result, args)); return 1; } } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류'; - writeErr(formatScenarioFailure(scenario, message, result)); + writeErr(formatScenarioFailure(scenario, message, result, args)); return 1; } } - writeOut(`MCP smoke 통과: ${smokeTools.length}개 도구 확인 및 ${scenarios.length}개 호출 완료`); + writeOut(`MCP smoke 통과: 공개 도구 10개 확인 및 ${scenarios.length}개 호출 완료`); return 0; } catch (error) { const message = error instanceof Error ? error.message : '알 수 없는 오류'; diff --git a/scripts/relay/daiso-jp.ts b/scripts/relay/daiso-jp.ts index 652d8533..c0d692b9 100644 --- a/scripts/relay/daiso-jp.ts +++ b/scripts/relay/daiso-jp.ts @@ -13,6 +13,7 @@ export function createJapanProductRelay( options: Pick, ) { let blockedUntil = 0; + let upstreamTail = Promise.resolve(); const relay = createHttpRelay(token, { ...options, prefix: '/v1/dtryx/', @@ -26,32 +27,45 @@ export function createJapanProductRelay( return { id: p.id }; }, async upstream(_operation, value, signal, fetcher) { - // 원본 제한은 품절이나 자체 할당량으로 바꾸지 않고 모든 상품에 대기를 공유합니다. - if (Date.now() < blockedUntil) throw new RelayError(429, undefined, undefined, 429); - const { id } = value as { id: string }; - const response = await abortable( - fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { - redirect: 'manual', + // 서로 다른 상품도 한 원본 진입을 공유하고 대기 중 취소를 보존합니다. + const previous = upstreamTail; + let release!: () => void; + const turn = new Promise((resolve) => { + release = resolve; + }); + upstreamTail = previous.then(() => turn); + try { + await abortable(previous, signal); + signal.throwIfAborted(); + // 원본 제한은 품절이나 자체 할당량으로 바꾸지 않고 모든 상품에 대기를 공유합니다. + if (Date.now() < blockedUntil) throw new RelayError(429, undefined, undefined, 429); + const { id } = value as { id: string }; + const response = await abortable( + fetcher(new URL(`https://jp.daisonet.com/products/${id}.js`), { + redirect: 'manual', + signal, + }), signal, - }), - signal, - ); - if (response.status === 429) - blockedUntil = - Date.now() + - Math.max(1000, Math.min(parseRetryAfterDelayMs(response) ?? 60000, 86400000)); - if (response.status !== 200) { - void response.body?.cancel().catch(() => undefined); - throw new RelayError( - response.status === 429 ? 429 : 502, - undefined, - undefined, - response.status, ); + if (response.status === 429) + blockedUntil = + Date.now() + + Math.max(1000, Math.min(parseRetryAfterDelayMs(response) ?? 60000, 86400000)); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError( + response.status === 429 ? 429 : 502, + undefined, + undefined, + response.status, + ); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + parseProductDetail(data, id); + return data; + } finally { + release(); } - const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); - parseProductDetail(data, id); - return data; }, }); return async (request: Request) => { diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index a4337020..77732b08 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -6,7 +6,11 @@ import { Readable } from 'node:stream'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { createFileQuota, assertSeparateQuotaDirectories, CONVENIENCE_QUOTA_LIMITS } from './quota.js'; +import { + createFileQuota, + assertSeparateQuotaDirectories, + CONVENIENCE_QUOTA_LIMITS, +} from './quota.js'; import { createConvenienceRelay } from './convenience.js'; import { createGs25SessionTransport } from './gs25-session.js'; import { createJapanProductRelay } from './daiso-jp.js'; @@ -27,29 +31,61 @@ async function main() { log.append({ stage: 'process', outcome: 'starting' }); const takeQuota = await createFileQuota(join(stateDir, 'quota.json')); log.append({ stage: 'quota', outcome: 'snapshot', ...takeQuota.status() }); - const heartbeat = setInterval( - () => log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }), - 60000, - ); + let maintainAuthentication = async () => {}; + const heartbeat = setInterval(() => { + log.append({ stage: 'process', outcome: 'heartbeat', ...takeQuota.status() }); + void maintainAuthentication(); + }, 60000); heartbeat.unref(); let convenience: ((request: Request) => Promise) | undefined; let convenienceLog: ReturnType | undefined; - if (process.env.CONVENIENCE_RELAY_TOKEN !== undefined || process.env.CONVENIENCE_RELAY_STATE_DIR !== undefined) { + if ( + process.env.CONVENIENCE_RELAY_TOKEN !== undefined || + process.env.CONVENIENCE_RELAY_STATE_DIR !== undefined + ) { const convenienceToken = process.env.CONVENIENCE_RELAY_TOKEN; const convenienceDir = process.env.CONVENIENCE_RELAY_STATE_DIR; - if (!convenienceToken?.trim() || !convenienceDir) throw Error('Invalid convenience configuration'); + if (!convenienceToken?.trim() || !convenienceDir) + throw Error('Invalid convenience configuration'); await mkdir(convenienceDir, { recursive: true, mode: 0o700 }); await assertSeparateQuotaDirectories(stateDir, convenienceDir); convenienceLog = createRelayLogger(join(convenienceDir, 'logs'), { service: 'convenience' }); - const quota = await createFileQuota(join(convenienceDir, 'quota.json'), Date.now, CONVENIENCE_QUOTA_LIMITS); + const quota = await createFileQuota( + join(convenienceDir, 'quota.json'), + Date.now, + CONVENIENCE_QUOTA_LIMITS, + ); const sessionFile = process.env.GS25_AUTH_SESSION_FILE?.trim(); + const session = sessionFile ? createGs25SessionTransport(sessionFile) : undefined; + if (session) { + maintainAuthentication = async () => { + try { + await session.prepare(); + } catch { + convenienceLog!.append({ + stage: 'authentication', + operation: 'gs25', + outcome: session.status().loginRequired ? 'login-required' : 'refresh-failed', + }); + } + }; + void maintainAuthentication(); + } const convenienceHandler = createConvenienceRelay(convenienceToken, { - takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY, - gs25Session: sessionFile ? createGs25SessionTransport(sessionFile) : undefined, + takeQuota: quota, + onEvent: convenienceLog.append, + gs25ApiKey: process.env.GS25_API_KEY, + gs25Session: session, }); convenience = observeRelay(async (request) => { const response = await convenienceHandler(request); - if (response.ok && new URL(request.url).pathname === '/v1/convenience/health') return Response.json({ ...(await response.json()), quota: quota.status(), logging: convenienceLog!.status() }); + if (response.ok && new URL(request.url).pathname === '/v1/convenience/health') + return Response.json({ + ...(await response.json()), + quota: quota.status(), + logging: convenienceLog!.status(), + authentication: session?.status(), + }); return response; }, convenienceLog.append); } @@ -69,16 +105,26 @@ async function main() { const cgvHandler = observeRelay(async (request) => { const response = await cgvRelay(request); if (response.ok && new URL(request.url).pathname === '/v1/cgv/health') { - return Response.json({ ...(await response.json()), quota: takeQuota.status(), logging: log.status() }); + return Response.json({ + ...(await response.json()), + quota: takeQuota.status(), + logging: log.status(), + }); } return response; }, log.append); const japanRelay = createJapanProductRelay(token, { takeQuota, onEvent: log.append }); const japanHandler = observeRelay(japanRelay, log.append); - const handler = (request: Request) => new URL(request.url).pathname.startsWith('/v1/convenience/') - ? convenience ? convenience(request) : Promise.resolve(Response.json({ error: 'Not found' }, { status: 404 })) - : new URL(request.url).pathname === '/v1/dtryx/daiso-jp-product' ? japanHandler(request) - : new URL(request.url).pathname.startsWith('/v1/cgv/') ? cgvHandler(request) : dtryxHandler(request); + const handler = (request: Request) => + new URL(request.url).pathname.startsWith('/v1/convenience/') + ? convenience + ? convenience(request) + : Promise.resolve(Response.json({ error: 'Not found' }, { status: 404 })) + : new URL(request.url).pathname === '/v1/dtryx/daiso-jp-product' + ? japanHandler(request) + : new URL(request.url).pathname.startsWith('/v1/cgv/') + ? cgvHandler(request) + : dtryxHandler(request); const server = createServer(async (req, res) => { const controller = new AbortController(); res.once('close', () => controller.abort()); diff --git a/scripts/relay/gs25-session.ts b/scripts/relay/gs25-session.ts index c884a587..436c6d2b 100644 --- a/scripts/relay/gs25-session.ts +++ b/scripts/relay/gs25-session.ts @@ -4,16 +4,23 @@ import { readFile, rename, stat, unlink, writeFile } from 'node:fs/promises'; import { GS25_API } from '../../src/services/gs25/api.js'; import { abortable, readJson, RelayError } from './http-relay.js'; -interface Session { accessToken: string; refreshToken: string; deviceId: string } +interface Session { + accessToken: string; + refreshToken: string; + deviceId: string; +} export type Gs25SessionTransport = (url: URL, init: RequestInit) => Promise; function expiration(token: unknown): number { - if (typeof token !== 'string' || !/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token)) throw new RelayError(403); + if (typeof token !== 'string' || !/^[\w-]+\.[\w-]+\.[\w-]+$/.test(token)) + throw new RelayError(403); try { const payload = JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString('utf8')); if (!Number.isFinite(payload.exp) || payload.exp <= 0) throw Error(); return payload.exp * 1000; - } catch { throw new RelayError(403); } + } catch { + throw new RelayError(403); + } } function parseSession(value: unknown): Session { @@ -21,30 +28,42 @@ function parseSession(value: unknown): Session { const session = value as Session; expiration(session.accessToken); expiration(session.refreshToken); - if (typeof session.deviceId !== 'string' || !/^[\w-]{1,100}$/.test(session.deviceId)) throw new RelayError(403); - return { accessToken: session.accessToken, refreshToken: session.refreshToken, deviceId: session.deviceId }; + if (typeof session.deviceId !== 'string' || !/^[\w-]{1,100}$/.test(session.deviceId)) + throw new RelayError(403); + return { + accessToken: session.accessToken, + refreshToken: session.refreshToken, + deviceId: session.deviceId, + }; } function appHeaders(session: Session): Headers { return new Headers({ - Accept: 'application/json', 'Content-Type': 'application/json', + Accept: 'application/json', + 'Content-Type': 'application/json', 'User-Agent': 'woodongs-app/5.3.61 (Android; 14; sdk_gphone64_arm64)', - request_id: randomUUID(), appinfo_device_id: session.deviceId, - appinfo_model_name: 'sdk_gphone64_arm64', appinfo_os_version: '14', - appinfo_app_version: '5.3.61', appinfo_app_build_number: '2132', appinfo_os_type: 'android', + request_id: randomUUID(), + appinfo_device_id: session.deviceId, + appinfo_model_name: 'sdk_gphone64_arm64', + appinfo_os_version: '14', + appinfo_app_version: '5.3.61', + appinfo_app_build_number: '2132', + appinfo_os_type: 'android', }); } export function createGs25SessionTransport( file: string, options: { fetcher?: typeof fetch; now?: () => number; refreshTimeoutMs?: number } = {}, -): Gs25SessionTransport { +) { const fetcher = options.fetcher ?? fetch; const now = options.now ?? Date.now; let loading: Promise | undefined; let current: Session | undefined; let refreshing: Promise | undefined; let revoked = false; + let prepareFailures = 0; + let retryAt = 0; async function load(): Promise { if (revoked) throw new RelayError(403); @@ -52,46 +71,88 @@ export function createGs25SessionTransport( loading ??= (async () => { try { const info = await stat(file); - if (!info.isFile() || info.size > 16384 || (info.mode & 0o077) !== 0 || info.uid !== process.getuid!()) throw Error(); + if ( + !info.isFile() || + info.size > 16384 || + (info.mode & 0o077) !== 0 || + info.uid !== process.getuid!() + ) + throw Error(); current = parseSession(JSON.parse(await readFile(file, 'utf8'))); return current; - } catch { throw new RelayError(403); } + } catch { + revoked = true; + throw new RelayError(403); + } })(); return loading; } async function renew(session: Session): Promise { - if (expiration(session.refreshToken) <= now()) { revoked = true; throw new RelayError(403); } + if (expiration(session.refreshToken) <= now()) { + revoked = true; + throw new RelayError(403); + } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), options.refreshTimeoutMs ?? 10000); const temporary = `${file}.${randomUUID()}.tmp`; try { const headers = appHeaders(session); headers.set('Refresh', session.refreshToken); - const response = await abortable(fetcher(new URL('/api/bff/v4/auth/tokenReissue', GS25_API.BFF_BASE_URL), { - method: 'POST', headers, redirect: 'manual', signal: controller.signal, - }), controller.signal); + const response = await abortable( + fetcher(new URL('/api/bff/v4/auth/tokenReissue', GS25_API.BFF_BASE_URL), { + method: 'POST', + headers, + redirect: 'manual', + signal: controller.signal, + }), + controller.signal, + ); if (!response.ok) { void response.body?.cancel().catch(() => undefined); - if ([401, 403].includes(response.status)) { revoked = true; throw new RelayError(403); } + if ([401, 403].includes(response.status)) { + revoked = true; + throw new RelayError(403); + } throw new RelayError(502); } - const value = await readJson(response.body, 131072, controller.signal, 502) as { resultCode?: string; data?: { access?: string; refresh?: string } }; - if (value.resultCode !== '0000' || !value.data?.access || !value.data.refresh) throw new RelayError(502); + const value = (await readJson(response.body, 131072, controller.signal, 502)) as { + resultCode?: string; + data?: { access?: string; refresh?: string }; + }; + if (value.resultCode !== '0000' || !value.data?.access || !value.data.refresh) + throw new RelayError(502); let next: Session; try { - next = parseSession({ accessToken: value.data.access.replace(/^Bearer /, ''), refreshToken: value.data.refresh.replace(/^Bearer /, ''), deviceId: session.deviceId }); - if (expiration(next.accessToken) <= now() + 60000 || expiration(next.refreshToken) <= now()) throw Error(); - } catch { throw new RelayError(502); } + next = parseSession({ + accessToken: value.data.access.replace(/^Bearer /, ''), + refreshToken: value.data.refresh.replace(/^Bearer /, ''), + deviceId: session.deviceId, + }); + if (expiration(next.accessToken) <= now() + 60000 || expiration(next.refreshToken) <= now()) + throw Error(); + } catch { + throw new RelayError(502); + } // 회전된 refresh 토큰은 파일 저장이 성공한 뒤 사용합니다. try { - await abortable((async () => { - await writeFile(temporary, JSON.stringify(next), { mode: 0o600, flag: 'wx', signal: controller.signal }); - if (controller.signal.aborted) throw new RelayError(504); - await rename(temporary, file); - if (controller.signal.aborted) throw new RelayError(504); - })(), controller.signal); - } catch { revoked = true; throw new RelayError(502); } + await abortable( + (async () => { + await writeFile(temporary, JSON.stringify(next), { + mode: 0o600, + flag: 'wx', + signal: controller.signal, + }); + if (controller.signal.aborted) throw new RelayError(504); + await rename(temporary, file); + if (controller.signal.aborted) throw new RelayError(504); + })(), + controller.signal, + ); + } catch { + revoked = true; + throw new RelayError(502); + } current = next; return next; } catch (error) { @@ -104,15 +165,39 @@ export function createGs25SessionTransport( } } - async function ready(forceFor?: string): Promise { + async function ready(forceFor?: string, margin = 60000): Promise { const session = await load(); - if ((!forceFor && expiration(session.accessToken) > now() + 60000) || (forceFor && session.accessToken !== forceFor)) return session; - if (!refreshing) refreshing = renew(session).finally(() => { refreshing = undefined; }); + if ( + (!forceFor && expiration(session.accessToken) > now() + margin) || + (forceFor && session.accessToken !== forceFor) + ) + return session; + if (now() < retryAt) throw new RelayError(502); + if (!refreshing) + refreshing = renew(session) + .then((next) => { + prepareFailures = 0; + retryAt = 0; + return next; + }) + .catch((error: unknown) => { + retryAt = now() + Math.min(900000, 60000 * 2 ** Math.min(prepareFailures++, 4)); + throw error; + }) + .finally(() => { + refreshing = undefined; + }); return refreshing; } - return async (url, init) => { - if (url.origin !== GS25_API.BFF_BASE_URL || url.pathname !== GS25_API.STORE_STOCK_PATH || url.username || url.password) throw new RelayError(400); + const send: Gs25SessionTransport = async (url, init) => { + if ( + url.origin !== GS25_API.BFF_BASE_URL || + url.pathname !== GS25_API.STORE_STOCK_PATH || + url.username || + url.password + ) + throw new RelayError(400); const signal = init.signal ?? new AbortController().signal; if (signal.aborted) throw new RelayError(499); const request = async (session: Session) => { @@ -120,7 +205,17 @@ export function createGs25SessionTransport( headers.delete('Api-Key'); appHeaders(session).forEach((value, key) => headers.set(key, value)); headers.set('Authorization', `Bearer ${session.accessToken}`); - return abortable(fetcher(url, { ...init, method: 'GET', body: undefined, headers, redirect: 'manual', signal }), signal); + return abortable( + fetcher(url, { + ...init, + method: 'GET', + body: undefined, + headers, + redirect: 'manual', + signal, + }), + signal, + ); }; const session = await abortable(ready(), signal); const response = await request(session); @@ -131,4 +226,33 @@ export function createGs25SessionTransport( if ([401, 403].includes(retry.status)) revoked = true; return retry; }; + let preparing: Promise | undefined; + return Object.assign(send, { + prepare(): Promise { + if (preparing) return preparing; + preparing = ready(undefined, 300000) + .then(() => undefined) + .finally(() => { + preparing = undefined; + }); + return preparing; + }, + status() { + return { + state: revoked + ? 'login_required' + : refreshing + ? 'refreshing' + : now() < retryAt + ? 'retry_wait' + : current + ? 'ready' + : 'unloaded', + loginRequired: revoked, + accessExpiresAt: current ? expiration(current.accessToken) : null, + refreshExpiresAt: current ? expiration(current.refreshToken) : null, + retryAt, + }; + }, + }); } diff --git a/scripts/relay/hide-oliveyoung.swift b/scripts/relay/hide-oliveyoung.swift new file mode 100644 index 00000000..a1d24325 --- /dev/null +++ b/scripts/relay/hide-oliveyoung.swift @@ -0,0 +1,76 @@ +import AppKit +import Foundation +import Darwin + +func argument(_ name: String, fallback: URL) -> URL { + guard let index = CommandLine.arguments.firstIndex(of: name) else { return fallback } + guard index + 1 < CommandLine.arguments.count, + CommandLine.arguments[index + 1].hasPrefix("/") else { exit(2) } + return URL(fileURLWithPath: CommandLine.arguments[index + 1]).standardizedFileURL +} +let base = argument("--state-base", fallback: FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support/DaisoRelay")) +let runtime = argument("--runtime", fallback: base.appendingPathComponent("runtime")) +let marker = base.appendingPathComponent("state/browser-owner.json") +let executable = runtime.appendingPathComponent("Browser.app/Contents/MacOS/Google Chrome for Testing").path + +func safeMarkerPath() -> Bool { + var path = marker + while path.path != "/" { + if path.resolvingSymlinksInPath().path != path.path { return false } + path.deleteLastPathComponent() + } + return true +} + +// 소유 마커가 바뀌거나 검증에 실패하면 숨김을 생략한다. +func target() -> NSRunningApplication? { + guard safeMarkerPath(), let attributes = try? FileManager.default.attributesOfItem(atPath: marker.path), + attributes[.type] as? FileAttributeType == .typeRegular, + attributes[.ownerAccountID] as? UInt32 == getuid(), + let mode = attributes[.posixPermissions] as? UInt16, mode & 0o077 == 0, + let size = attributes[.size] as? UInt64, size <= 16384, + let data = try? Data(contentsOf: marker), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let guardPid = object["guardPid"] as? Int, guardPid > 1, guardPid <= Int(Int32.max), + let browser = object["browser"] as? [String: Any], + let pid = browser["pid"] as? Int, pid > 1, pid <= Int(Int32.max), + browser["ppid"] as? Int == guardPid, + let birth = browser["birth"] as? String, + let app = NSRunningApplication(processIdentifier: pid_t(pid)), + app.executableURL?.path == executable, !app.isTerminated else { return nil } + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/ps") + process.arguments = ["-p", String(pid), "-o", "uid=,ppid=,lstart="] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + guard (try? process.run()) != nil else { return nil } + let output = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let fields = String(decoding: output, as: UTF8.self).split(whereSeparator: { $0.isWhitespace }) + guard process.terminationStatus == 0, fields.count == 7, + UInt32(fields[0]) == getuid(), Int(fields[1]) == guardPid, + fields.dropFirst(2).joined(separator: " ") == birth.split(whereSeparator: { $0.isWhitespace }).joined(separator: " "), + safeMarkerPath(), (try? Data(contentsOf: marker)) == data else { return nil } + return app +} + +// 매초 확인하되 이미 숨겨진 앱에는 추가 UI 요청을 보내지 않는다. +func hideOwnedBrowser() { + guard let app = target(), !app.isHidden else { return } + _ = app.hide() +} + +if CommandLine.arguments.contains("--check") { + if let app = target() { + print("owned=true hidden=\(app.isHidden) pid=\(app.processIdentifier)") + } else { + print("owned=false") + exit(1) + } +} else { + hideOwnedBrowser() + let timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in hideOwnedBrowser() } + RunLoop.main.add(timer, forMode: .common) + RunLoop.main.run() +} diff --git a/scripts/relay/macos-ops.py b/scripts/relay/macos-ops.py new file mode 100644 index 00000000..c8bf9c32 --- /dev/null +++ b/scripts/relay/macos-ops.py @@ -0,0 +1,403 @@ +#!/usr/bin/python3 +"""현재 사용자의 Mac 중계 보조 도구. plan/status/install/monitor는 비밀값을 출력하지 않습니다.""" +import argparse +import hashlib +import http.client +import json +import math +import os +from pathlib import Path +import plistlib +import shlex +import shutil +import stat +import socket +import threading +import subprocess +import sys +import tempfile +import time + +HIDE_LABEL = 'page.aka.daiso-ops-hide' +MONITOR_LABEL = 'page.aka.daiso-ops-monitor' +OWNER = 'daiso-mcp-macos-ops-v1' +CAP = 65536 + + +def validate_path(path, private=False, directory=False): + path = Path(path) + if not path.is_absolute(): + raise ValueError('Absolute path required') + for part in [path, *path.parents]: + if part.is_symlink(): + raise ValueError('Symbolic link refused') + if path.exists(): + info = path.stat() + if info.st_uid != os.getuid(): + raise ValueError('Foreign owner refused') + if directory != stat.S_ISDIR(info.st_mode): + raise ValueError('Unexpected file type') + if not directory and not stat.S_ISREG(info.st_mode): + raise ValueError('Regular file required') + if info.st_mode & (0o077 if private else 0o022): + raise ValueError('Unsafe permissions') + return path + + +def private_json(path): + validate_path(path, private=True) + if path.stat().st_size > CAP: + raise ValueError('Private file too large') + return json.loads(path.read_text()) + + +def read_env(path): + validate_path(path, private=True) + if path.stat().st_size > CAP: + raise ValueError('Environment file too large') + values = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + if '=' not in line: + raise ValueError('Invalid environment file') + key, value = line.split('=', 1) + if key.startswith('export '): + key = key[7:] + parts = shlex.split(value, comments=True) + if len(parts) > 1 or not key.replace('_', '').isalnum(): + raise ValueError('Invalid environment value') + values[key] = parts[0] if parts else '' + return values + + +def safe_health(value): + if not isinstance(value, dict): + raise ValueError('Invalid health object') + result = {} + # 허용된 상태와 숫자만 남기고 원본 문자열·응답·토큰은 저장하지 않습니다. + for key in ['status', 'state']: + if value.get(key) in ['ok', 'ready', 'running', 'starting', 'failed', 'stopped', 'unavailable', 'needs-login', 'healthy', 'degraded', 'closed', 'idle']: + result[key] = value[key] + for key in ['active', 'outstanding', 'queued', 'consecutiveFailures']: + if type(value.get(key)) is int and 0 <= value[key] <= 1000000: + result[key] = value[key] + if type(value.get('firstFailedAt')) in [int, float] and math.isfinite(value['firstFailedAt']) and value['firstFailedAt'] >= 0: + result['firstFailedAt'] = value['firstFailedAt'] + if value.get('reason') in ['health-unavailable', 'readiness-unavailable', 'authentication-unobservable', 'own-quota']: + result['reason'] = value['reason'] + auth = value.get('authentication') + if isinstance(auth, dict): + result['authentication'] = {} + if auth.get('state') in ['ready', 'missing', 'expired', 'refreshing', 'failed', 'needs-login', 'login-required', 'refresh-failed', 'unavailable', 'login_required', 'retry_wait', 'unloaded']: + result['authentication']['state'] = auth['state'] + if type(auth.get('loginRequired')) is bool: + result['authentication']['loginRequired'] = auth['loginRequired'] + for key in ['accessExpiresAt', 'refreshExpiresAt', 'retryAt']: + item = auth.get(key) + if item is None or (type(item) in [int, float] and math.isfinite(item) and item >= 0): + result['authentication'][key] = item + for section in ['logging', 'quota']: + data = value.get(section) + if isinstance(data, dict): + result[section] = {key: item for key, item in data.items() + if key in ['enabled', 'failed', 'remaining', 'used', 'limit', 'day', 'month', 'requests', 'bytes', 'dropped', 'errors', 'lowSpace', 'queued', 'written', 'dailyRemaining', 'minuteRemaining', 'retryAfter'] + and (type(item) is bool or (type(item) is int and 0 <= item <= 10**12))} + quota = value.get('quota') + if isinstance(quota, dict) and 'blockedBy' in quota and quota['blockedBy'] in [None, 'minute', 'daily']: + result['quota']['blockedBy'] = quota['blockedBy'] + return result + + +def authentication_observable(value): + auth = value.get('authentication') + if not isinstance(auth, dict) or type(auth.get('loginRequired')) is not bool: + return False + if auth.get('state') not in ['ready', 'refreshing', 'retry_wait', 'login_required', 'unloaded']: + return False + if auth['state'] == 'ready': + now_ms = time.time() * 1000 + for key in ['accessExpiresAt', 'refreshExpiresAt']: + expiry = auth.get(key) + if type(expiry) not in [int, float] or not math.isfinite(expiry) or expiry <= now_ms: + return False + return True + + +def check_failed(value): + return ((value.get('status') != 'ok' and value.get('state') != 'ready') + or value.get('state') in ['failed', 'closed', 'stopped', 'unavailable', 'degraded'] + or value.get('authentication', {}).get('loginRequired') is True + or value.get('authentication', {}).get('state') in ['login_required', 'retry_wait', 'unloaded', 'failed'] + or value.get('logging', {}).get('failed') is True + or value.get('logging', {}).get('lowSpace') is True + or value.get('logging', {}).get('errors', 0) > 0 + or value.get('quota', {}).get('blockedBy') in ['minute', 'daily']) + + +def fetch_health(port, path, token=None): + # http.client는 환경 프록시를 사용하거나 리디렉션을 따라가지 않습니다. + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=3) + timer = None + expired = threading.Event() + try: + connection.connect() + transport = connection.sock + def expire(): + expired.set() + try: + transport.shutdown(socket.SHUT_RDWR) + except OSError: + pass + timer = threading.Timer(4, expire) + timer.daemon = True + timer.start() + headers = {'Authorization': 'Bearer ' + token} if token else {} + connection.request('GET', path, headers=headers) + response = connection.getresponse() + deadline = time.monotonic() + 4 + chunks = [] + size = 0 + while True: + if time.monotonic() > deadline: + raise ValueError('Health deadline') + part = response.read(min(4096, CAP + 1 - size)) + size += len(part) + if size > CAP: + raise ValueError('Health body limit') + if not part: + break + chunks.append(part) + if expired.is_set() or response.status != 200: + raise ValueError('Health HTTP failure') + return json.loads(b''.join(chunks)) if token else {'status': 'ok'} + finally: + if timer: + timer.cancel() + connection.close() + + +def write_snapshot(path, value): + validate_path(path, private=True) + validate_path(path.parent, private=True, directory=True) + content = (json.dumps(value, allow_nan=False) + '\n').encode() + if len(content) > CAP: + raise ValueError('Snapshot too large') + descriptor, name = tempfile.mkstemp(prefix='.ops-', dir=str(path.parent)) + try: + with os.fdopen(descriptor, 'wb') as stream: + os.fchmod(stream.fileno(), 0o600) + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + validate_path(path, private=True) + os.replace(name, path) + finally: + if os.path.exists(name): + os.unlink(name) + + +def login_notification(checks, previous, now, enabled): + required = checks.get('convenience', {}).get('authentication', {}).get('loginRequired') is True + if not required: + return {'state': 'not-required'} + old = previous.get('notification', {}) + if not enabled: + return {'state': 'disabled'} + last = old.get('attemptedAt', 0) + if type(last) in [int, float] and 0 <= now - last < 21600 and old.get('state') in ['accepted', 'failed']: + return old + result = {'state': 'failed', 'attemptedAt': now} + try: + subprocess.run(['/usr/bin/osascript', '-e', + 'display notification "GS25 정상 로그인이 필요합니다. 운영 상태와 복구 절차를 확인해 주세요." with title "DaisoRelay 인증 확인"'], + check=True, capture_output=True, timeout=5) + result['state'] = 'accepted' + except (OSError, subprocess.SubprocessError): + pass + return result + + +def monitor(base, tunnel_port, notify_local=False): + checks = {} + for name, env_file, token_key, port, path in [ + ('oliveyoung', 'relay.env', 'OY_RELAY_TOKEN', 4319, '/health'), + ('dtryx', 'dtryx.env', 'DTRYX_RELAY_TOKEN', 4320, '/v1/dtryx/health'), + ('cgv', 'dtryx.env', 'DTRYX_RELAY_TOKEN', 4320, '/v1/cgv/health'), + ('convenience', 'dtryx.env', 'CONVENIENCE_RELAY_TOKEN', 4320, '/v1/convenience/health'), + ]: + try: + environment = read_env(base / env_file) + token = environment.get(token_key) + if not token or '\r' in token or '\n' in token: + raise ValueError('Missing token') + safe = safe_health(fetch_health(port, path, token)) + checks[name] = dict(safe, failed=check_failed(safe)) + if safe.get('quota', {}).get('blockedBy') in ['minute', 'daily']: + checks[name]['reason'] = 'own-quota' + if name == 'convenience' and environment.get('GS25_AUTH_SESSION_FILE', '').strip() and not authentication_observable(safe): + checks[name].update(failed=True, reason='authentication-unobservable') + except (OSError, ValueError, http.client.HTTPException): + checks[name] = {'failed': True, 'reason': 'health-unavailable'} + if tunnel_port: + try: + fetch_health(tunnel_port, '/ready') + checks['tunnel'] = {'failed': False, 'status': 'ok'} + except (OSError, ValueError, http.client.HTTPException): + checks['tunnel'] = {'failed': True, 'reason': 'readiness-unavailable'} + previous_status = snapshot_status(base / 'ops-status.json') + previous = previous_status.get('checks', {}) + checked_at = time.time() + for name, check in checks.items(): + old = previous.get(name, {}) + if check['failed']: + check['consecutiveFailures'] = min(old.get('consecutiveFailures', 0) + 1, 1000000) + check['firstFailedAt'] = old.get('firstFailedAt', checked_at) + else: + check['consecutiveFailures'] = 0 + result = {'checkedAt': checked_at, 'failed': any(v['failed'] for v in checks.values()), 'checks': checks} + result['notification'] = login_notification(checks, previous_status, checked_at, notify_local) + write_snapshot(base / 'ops-status.json', result) + return result + + +def snapshot_status(path, now=None): + try: + value = private_json(path) + checked = value.get('checkedAt') + if type(checked) not in [int, float] or not math.isfinite(checked): + raise ValueError('Invalid snapshot') + # 임의로 바뀐 스냅숏도 출력 전에 다시 허용 목록으로 제한합니다. + checks = {name: dict(safe_health(item), failed=item.get('failed') is True) + for name, item in value.get('checks', {}).items() + if name in ['oliveyoung', 'dtryx', 'cgv', 'convenience', 'tunnel'] and isinstance(item, dict)} + notification = value.get('notification', {}) + safe_notification = {'state': notification.get('state') if notification.get('state') in ['accepted', 'failed', 'disabled', 'not-required'] else 'unknown'} + attempted = notification.get('attemptedAt') + if type(attempted) in [int, float] and math.isfinite(attempted) and attempted >= 0: + safe_notification['attemptedAt'] = attempted + return {'notification': safe_notification, 'checkedAt': checked, 'stale': not 0 <= (time.time() if now is None else now) - checked <= 180, + 'failed': not all(name in checks for name in ['oliveyoung', 'dtryx', 'cgv', 'convenience']) or any(item['failed'] for item in checks.values()), 'checks': checks} + except FileNotFoundError: + return {'stale': True, 'failed': True, 'reason': 'snapshot-missing'} + + +def agent_plists(base, home, version, tunnel_port, notify_local=False): + common = {'RunAtLoad': True, 'LimitLoadToSessionType': 'Aqua', 'ProcessType': 'Background', + 'StandardOutPath': '/dev/null', 'StandardErrorPath': '/dev/null', + 'EnvironmentVariables': {'DAISO_OPS_OWNER': OWNER}} + hide = dict(common, Label=HIDE_LABEL, KeepAlive=True, ThrottleInterval=60, + ProgramArguments=[str(version / 'hide-oliveyoung'), '--state-base', str(base), '--runtime', str(home)]) + mon = dict(common, Label=MONITOR_LABEL, StartInterval=60, + ProgramArguments=['/usr/bin/python3', str(version / 'macos-ops.py'), 'monitor', + '--state-base', str(base), '--tunnel-port', str(tunnel_port)]) + if notify_local: + mon['ProgramArguments'].append('--notify-local') + return {HIDE_LABEL: hide, MONITOR_LABEL: mon} + + +def make_plan(base, runtime, tunnel_port): + if not 0 <= tunnel_port <= 65535 or 0 < tunnel_port < 1024: + raise ValueError('Invalid tunnel port') + for path in [base, runtime]: + validate_path(path, directory=True) + return {'stateBase': str(base), 'runtime': str(runtime), + 'labels': [HIDE_LABEL, MONITOR_LABEL], 'tunnelPort': tunnel_port, + 'preserves': ['relay.env', 'dtryx.env', 'credentials', 'quota', 'runtime', 'existing LaunchAgents']} + + +def install(base, runtime, tunnel_port, agents, notify_local=False): + make_plan(base, runtime, tunnel_port) + validate_path(base, private=True, directory=True) + validate_path(agents, directory=True) + source = Path(__file__).absolute().parent + payloads = {name: (source / name).read_bytes() for name in ['macos-ops.py', 'hide-oliveyoung.swift']} + digest = hashlib.sha256(b''.join(payloads.values())).hexdigest()[:16] + helpers = base / 'ops-helpers' + version = helpers / digest + validate_path(helpers, private=True, directory=True) + validate_path(version, private=True, directory=True) + plists = agent_plists(base, runtime, version, tunnel_port, notify_local) + for label in plists: + path = agents / (label + '.plist') + validate_path(path) + if path.exists() and plistlib.loads(path.read_bytes()).get('EnvironmentVariables', {}).get('DAISO_OPS_OWNER') != OWNER: + raise ValueError('Foreign LaunchAgent refused') + base.mkdir(parents=True, exist_ok=True, mode=0o700) + helpers.mkdir(mode=0o700, exist_ok=True) + agents.mkdir(parents=True, exist_ok=True, mode=0o700) + if not version.exists(): + stage = Path(tempfile.mkdtemp(prefix='.stage-', dir=str(helpers))) + try: + for name, content in payloads.items(): + (stage / name).write_bytes(content) + (stage / name).chmod(0o600) + subprocess.run(['/usr/bin/swiftc', str(stage / 'hide-oliveyoung.swift'), '-o', str(stage / 'hide-oliveyoung')], check=True, capture_output=True) + (stage / 'hide-oliveyoung').chmod(0o700) + stage.rename(version) + finally: + if stage.exists(): + shutil.rmtree(stage) + else: + for name, content in payloads.items(): + validate_path(version / name, private=True) + if (version / name).read_bytes() != content: + raise ValueError('Modified managed helper refused') + validate_path(version / 'hide-oliveyoung', private=True) + if not os.access(version / 'hide-oliveyoung', os.X_OK): + raise ValueError('Managed helper is not executable') + for label, plist in plists.items(): + path = agents / (label + '.plist') + if path.exists(): + backup = path.with_name(path.name + '.backup-' + str(time.time_ns())) + descriptor = os.open(backup, os.O_CREAT | os.O_EXCL | os.O_WRONLY | os.O_NOFOLLOW, 0o600) + with os.fdopen(descriptor, 'wb') as stream: + stream.write(path.read_bytes()) + descriptor, temporary = tempfile.mkstemp(prefix='.ops-plist-', dir=str(agents)) + try: + with os.fdopen(descriptor, 'wb') as stream: + stream.write(plistlib.dumps(plist)) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return {'installed': True, 'loaded': False, 'labels': list(plists)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('command', choices=['plan', 'status', 'install', 'monitor']) + parser.add_argument('--state-base', type=Path, default=Path.home() / 'Library/Application Support/DaisoRelay') + parser.add_argument('--runtime', type=Path) + parser.add_argument('--tunnel-port', type=int, default=20242, help='0 disables tunnel readiness') + parser.add_argument('--apply', action='store_true') + parser.add_argument('--notify-local', action='store_true', help='Enable fixed local login-required notification') + args = parser.parse_args() + base = args.state_base + runtime = args.runtime or base / 'runtime' + try: + if args.command == 'status': + validate_path(base, private=True, directory=True) + result = snapshot_status(base / 'ops-status.json') + elif args.command == 'monitor': + if not 0 <= args.tunnel_port <= 65535 or 0 < args.tunnel_port < 1024: + raise ValueError('Invalid tunnel port') + result = monitor(base, args.tunnel_port, args.notify_local) + elif args.command == 'install' and args.apply: + if sys.platform != 'darwin' or os.getuid() == 0: + raise ValueError('Run as current Mac GUI user') + result = install(base, runtime, args.tunnel_port, Path.home() / 'Library/LaunchAgents', args.notify_local) + else: + result = make_plan(base, runtime, args.tunnel_port) + result['applied'] = False + result['notifyLocal'] = args.notify_local + print(json.dumps(result, allow_nan=False)) + except (OSError, ValueError, subprocess.SubprocessError): + print(json.dumps({'failed': True, 'reason': 'unsafe-or-unavailable-input'})) + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/tests/relay/daiso-jp.test.ts b/tests/relay/daiso-jp.test.ts index 0d3926d8..655b7f20 100644 --- a/tests/relay/daiso-jp.test.ts +++ b/tests/relay/daiso-jp.test.ts @@ -123,3 +123,49 @@ it('원본429 대기는 표준 헤더로 제공하고 자체 분당 제한은 vi.useRealTimers(); } }); + +it('서로 다른 상품이 동시에 들어와도 첫 원본429 뒤 추가 원본 요청을 시작하지 않는다', async () => { + let finish!: (response: Response) => void; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + const first = handler(req({ id: raw.handle })); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + const second = handler(req({ id: '4550746075918' })); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetcher).toHaveBeenCalledTimes(1); + finish(new Response(null, { status: 429, headers: { 'Retry-After': '60' } })); + expect((await first).status).toBe(429); + expect((await second).status).toBe(429); + expect(fetcher).toHaveBeenCalledTimes(1); +}); +it('대기 취소가 앞 원본을 취소하거나 뒤 요청을 먼저 시작시키지 않는다', async () => { + let finish!: (response: Response) => void; + const fetcher = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve; + }), + ) + .mockResolvedValue(Response.json({ ...raw, handle: '4550746075919' })); + const handler = createJapanProductRelay('test-token', { takeQuota: async () => true, fetcher }); + const first = handler(req({ id: raw.handle })); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + const controller = new AbortController(); + const second = handler(new Request(req({ id: '4550746075918' }), { signal: controller.signal })); + controller.abort(); + expect((await second).status).toBe(499); + const third = handler(req({ id: '4550746075919' })); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetcher).toHaveBeenCalledTimes(1); + finish(Response.json(raw)); + expect((await first).status).toBe(200); + expect((await third).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/tests/relay/gs25-maintenance.test.ts b/tests/relay/gs25-maintenance.test.ts new file mode 100644 index 00000000..676d0af5 --- /dev/null +++ b/tests/relay/gs25-maintenance.test.ts @@ -0,0 +1,97 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createGs25SessionTransport } from '../../scripts/relay/gs25-session.js'; +const dirs: string[] = []; +const time = 1800000000000; +const jwt = (ms: number) => + `e30.${Buffer.from(JSON.stringify({ exp: ms / 1000 })).toString('base64url')}.fixture`; +async function file(access = time + 3600000, refresh = time + 86400000) { + const dir = await mkdtemp(join(tmpdir(), 'gs-maintenance-')); + dirs.push(dir); + const path = join(dir, 'auth.json'); + await writeFile( + path, + JSON.stringify({ accessToken: jwt(access), refreshToken: jwt(refresh), deviceId: 'fixture' }), + { mode: 0o600 }, + ); + return path; +} +afterEach(async () => { + await Promise.all(dirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); +it('재고 요청 없이 인증을 준비하고 토큰 대신 안전한 기한만 노출한다', async () => { + const fetcher = vi.fn(); + const send = createGs25SessionTransport(await file(), { fetcher, now: () => time }); + expect(send.status()).toMatchObject({ state: 'unloaded', loginRequired: false }); + await send.prepare(); + expect(fetcher).not.toHaveBeenCalled(); + expect(send.status()).toEqual({ + state: 'ready', + loginRequired: false, + accessExpiresAt: time + 3600000, + refreshExpiresAt: time + 86400000, + retryAt: 0, + }); + expect(JSON.stringify(send.status())).not.toContain('fixture'); +}); +it('5분 내 만료는 공유 준비로 한 번만 갱신하고 재고를 호출하지 않는다', async () => { + let finish!: (r: Response) => void; + const fetcher = vi.fn( + () => + new Promise((resolve) => { + finish = resolve; + }), + ); + const send = createGs25SessionTransport(await file(time + 120000), { fetcher, now: () => time }); + const one = send.prepare(); + const two = send.prepare(); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1)); + expect(send.status().state).toBe('refreshing'); + finish( + Response.json({ + resultCode: '0000', + data: { access: jwt(time + 7200000), refresh: jwt(time + 86400000) }, + }), + ); + await Promise.all([one, two]); + expect(send.status().state).toBe('ready'); + expect(String(fetcher.mock.calls[0]![0])).toContain('tokenReissue'); +}); +it('일시 갱신 실패는 대기를 공유하고 만료와 로그인 필요를 구분한다', async () => { + let clock = time; + const fetcher = vi.fn(async () => new Response(null, { status: 500 })); + const send = createGs25SessionTransport(await file(time - 1), { fetcher, now: () => clock }); + await expect(send.prepare()).rejects.toMatchObject({ status: 502 }); + expect(send.status()).toMatchObject({ + state: 'retry_wait', + loginRequired: false, + retryAt: time + 60000, + }); + await expect(send.prepare()).rejects.toMatchObject({ status: 502 }); + expect(fetcher).toHaveBeenCalledTimes(1); + clock += 60001; + await expect(send.prepare()).rejects.toMatchObject({ status: 502 }); + expect(fetcher).toHaveBeenCalledTimes(2); +}); +it('실제 인증 거절과 손상 파일은 로그인 필요로 표시한다', async () => { + const send = createGs25SessionTransport(await file(time - 1), { + fetcher: async () => new Response(null, { status: 401 }), + now: () => time, + }); + await expect(send.prepare()).rejects.toMatchObject({ status: 403 }); + expect(send.status()).toMatchObject({ state: 'login_required', loginRequired: true }); + const broken = createGs25SessionTransport('/nonexistent/auth.json', { now: () => time }); + await expect(broken.prepare()).rejects.toMatchObject({ status: 403 }); + expect(broken.status().loginRequired).toBe(true); +}); +it('자동 준비 실패의 갱신 대기를 실제 재고 요청도 우회하지 않는다', async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 500 })); + const send = createGs25SessionTransport(await file(time - 1), { fetcher, now: () => time }); + await expect(send.prepare()).rejects.toMatchObject({ status: 502 }); + await expect( + send(new URL('https://b2c-bff.woodongs.com/api/bff/v2/store/stock'), {}), + ).rejects.toMatchObject({ status: 502 }); + expect(fetcher).toHaveBeenCalledTimes(1); +}); diff --git a/tests/scripts/fixtures/macos_ops_tests.py b/tests/scripts/fixtures/macos_ops_tests.py new file mode 100644 index 00000000..53440991 --- /dev/null +++ b/tests/scripts/fixtures/macos_ops_tests.py @@ -0,0 +1,217 @@ +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +import unittest +from unittest.mock import patch + +spec = importlib.util.spec_from_file_location('ops', 'scripts/relay/macos-ops.py') +ops = importlib.util.module_from_spec(spec) +spec.loader.exec_module(ops) + +class OpsTests(unittest.TestCase): + def test_private_files_and_symlinks(self): + with tempfile.TemporaryDirectory() as root: + path = Path(root).resolve() / 'env' + path.write_text('OY_RELAY_TOKEN="secret"\nOTHER=x\nOPTIONAL=\nCOMMENTED= # unset\n') + path.chmod(0o600) + self.assertEqual(ops.read_env(path)['OY_RELAY_TOKEN'], 'secret') + self.assertEqual(ops.read_env(path)['OPTIONAL'], '') + self.assertEqual(ops.read_env(path)['COMMENTED'], '') + link = Path(root).resolve() / 'link' + link.symlink_to(path) + with self.assertRaises(ValueError): ops.read_env(link) + path.chmod(0o644) + with self.assertRaises(ValueError): ops.read_env(path) + + def test_allowlist_and_failures(self): + value = {'status':'ok', 'token':'secret', 'authentication': { + 'state':'ready', 'loginRequired':False, 'accessExpiresAt':123, + 'refreshExpiresAt':None, 'retryAt':None, 'accessToken':'secret'}} + safe = ops.safe_health(value) + self.assertNotIn('secret', json.dumps(safe)) + self.assertEqual(safe['authentication']['accessExpiresAt'], 123) + self.assertFalse(ops.check_failed(safe)) + self.assertFalse(ops.check_failed({'state':'ready'})) + self.assertTrue(ops.check_failed({'state':'starting'})) + self.assertTrue(ops.check_failed({'state':'idle'})) + self.assertTrue(ops.check_failed({'status':'ok','authentication':{'state':'retry_wait'}})) + self.assertTrue(ops.check_failed({'status':'ok','logging':{'errors':1}})) + self.assertTrue(ops.check_failed({'status':'ok','authentication':{'loginRequired':True}})) + self.assertTrue(ops.check_failed({'status':'failed'})) + self.assertTrue(ops.check_failed({'status':'ok','state':'failed'})) + + def test_own_quota_is_distinct_from_upstream_failure(self): + for blocked in ['daily', 'minute']: + safe=ops.safe_health({'status':'ok','quota':{'blockedBy':blocked,'dailyRemaining':0},'quantity':0}) + self.assertEqual(safe['quota']['blockedBy'],blocked) + self.assertEqual(safe['quota']['dailyRemaining'],0) + self.assertTrue(ops.check_failed(safe)) + self.assertEqual(ops.safe_health({'quota':{'blockedBy':None}})['quota']['blockedBy'],None) + self.assertNotIn('blockedBy',ops.safe_health({'quota':{'blockedBy':'upstream429'}})['quota']) + with tempfile.TemporaryDirectory() as root: + base=Path(root).resolve() + for name in ['relay.env','dtryx.env']: + (base/name).write_text('OY_RELAY_TOKEN=secret\nDTRYX_RELAY_TOKEN=secret\nCONVENIENCE_RELAY_TOKEN=secret\n') + (base/name).chmod(0o600) + with patch.object(ops,'fetch_health',return_value={'status':'ok','quota':{'blockedBy':'daily','dailyRemaining':0}}): + result=ops.monitor(base,0) + self.assertEqual(result['checks']['dtryx']['reason'],'own-quota') + self.assertEqual(ops.snapshot_status(base/'ops-status.json')['checks']['dtryx']['reason'],'own-quota') + + def test_plan_and_agents(self): + with tempfile.TemporaryDirectory() as root: + base = Path(root).resolve() / 'state' + plan = ops.make_plan(base, Path(root).resolve() / 'runtime', 20242) + self.assertFalse(base.exists()) + self.assertEqual(len(plan['labels']), 2) + agents = ops.agent_plists(base, Path(root).resolve(), Path(root).resolve() / 'version', 20242) + self.assertEqual(agents[ops.MONITOR_LABEL]['StartInterval'], 60) + self.assertFalse(agents[ops.MONITOR_LABEL].get('KeepAlive', False)) + self.assertIn('--state-base', agents[ops.HIDE_LABEL]['ProgramArguments']) + with self.assertRaises(ValueError): ops.make_plan(base, base, 65536) + + def test_atomic_snapshot_and_staleness(self): + with tempfile.TemporaryDirectory() as root: + base = Path(root).resolve() + path = base / 'ops-status.json' + ops.write_snapshot(path, {'checkedAt':10,'checks':{}}) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertTrue(ops.snapshot_status(path, now=191)['stale']) + path.unlink() + path.symlink_to(base / 'foreign') + with self.assertRaises(ValueError): ops.write_snapshot(path, {}) + + def test_monitor_local_only_sanitized(self): + with tempfile.TemporaryDirectory() as root: + base=Path(root).resolve() + for name in ['relay.env', 'dtryx.env']: + (base/name).write_text('OY_RELAY_TOKEN=secret\nDTRYX_RELAY_TOKEN=secret\nCONVENIENCE_RELAY_TOKEN=secret\n') + (base/name).chmod(0o600) + seen=[] + def fetch(port,path,token=None): + seen.append((port,path)) + return {'status':'ok', 'token':'secret'} + with patch.object(ops, 'fetch_health', side_effect=fetch): + result=ops.monitor(base, 20242) + self.assertEqual(len(seen),5) + self.assertTrue(all(port in [4319,4320,20242] for port,path in seen)) + self.assertNotIn('secret',json.dumps(result)) + self.assertFalse(result['failed']) + with patch.object(ops, 'fetch_health', side_effect=ValueError('failed')): + first=ops.monitor(base,0) + second=ops.monitor(base,0) + self.assertEqual(second['checks']['oliveyoung']['consecutiveFailures'],2) + self.assertEqual(first['checks']['oliveyoung']['firstFailedAt'], second['checks']['oliveyoung']['firstFailedAt']) + + def test_configured_gs_session_requires_observable_authentication(self): + with tempfile.TemporaryDirectory() as root: + base=Path(root).resolve() + for name in ['relay.env', 'dtryx.env']: + (base/name).write_text('OY_RELAY_TOKEN=secret\nDTRYX_RELAY_TOKEN=secret\nCONVENIENCE_RELAY_TOKEN=secret\nGS25_AUTH_SESSION_FILE=/private/session-secret\n') + (base/name).chmod(0o600) + invalid=[None, {}, {'state':'ready'}, {'state':'ready','loginRequired':'false'}, + {'state':'ready','loginRequired':False}, + {'state':'ready','loginRequired':False,'accessExpiresAt':None,'refreshExpiresAt':None}, + {'state':'ready','loginRequired':False,'accessExpiresAt':1,'refreshExpiresAt':2}, + {'state':'unknown','loginRequired':False}] + for authentication in invalid: + with self.subTest(authentication=authentication): + response={'status':'ok','authentication':authentication} + with patch.object(ops,'fetch_health',return_value=response): result=ops.monitor(base,0) + check=result['checks']['convenience'] + self.assertTrue(check['failed']) + self.assertEqual(check['reason'],'authentication-unobservable') + self.assertNotIn('session-secret',json.dumps(result)) + for state in ['ready', 'refreshing', 'retry_wait']: + response={'status':'ok','authentication':{'state':state,'loginRequired':False, + 'accessExpiresAt':(time.time()+600)*1000,'refreshExpiresAt':(time.time()+1200)*1000}} + with patch.object(ops,'fetch_health',return_value=response): result=ops.monitor(base,0) + self.assertNotEqual(result['checks']['convenience'].get('reason'),'authentication-unobservable') + (base/'dtryx.env').write_text('DTRYX_RELAY_TOKEN=secret\nCONVENIENCE_RELAY_TOKEN=secret\nGS25_AUTH_SESSION_FILE=\n') + with patch.object(ops,'fetch_health',return_value={'status':'ok'}): result=ops.monitor(base,0) + self.assertFalse(result['checks']['convenience']['failed']) + + def test_local_login_notification_deduplicates_and_reports_failure(self): + checks={'convenience': {'authentication': {'loginRequired':True}}} + with patch.object(ops.subprocess,'run') as run: + result=ops.login_notification(checks, {}, 100, True) + self.assertEqual(result['state'],'accepted') + ops.login_notification(checks, {'notification':result}, 101, True) + self.assertEqual(run.call_count,1) + with patch.object(ops.subprocess,'run',side_effect=OSError()): + result=ops.login_notification(checks, {}, 100, True) + self.assertEqual(result['state'],'failed') + self.assertTrue(ops.snapshot_status(Path('/nonexistent/ops-status.json'))['failed']) + + def test_http_redirect_body_cap_proxy_bypass(self): + class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): pass + def do_GET(self): + self.send_response(302 if self.path == '/redirect' else 200) + self.send_header('Location', 'http://example.invalid/secret') + self.end_headers() + if self.path == '/slow': + try: + for _ in range(20): + self.wfile.write(b'x') + self.wfile.flush() + time.sleep(0.02) + except (BrokenPipeError, ConnectionResetError): + pass + return + self.wfile.write(b'x' * (ops.CAP + 1) if self.path == '/large' else b'{"status":"ok"}') + server = HTTPServer(('127.0.0.1', 0), Handler) + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() + try: + with patch.dict(os.environ, {'HTTP_PROXY':'http://127.0.0.1:1'}): + self.assertEqual(ops.fetch_health(server.server_port,'/health','secret')['status'], 'ok') + with self.assertRaises(ValueError): ops.fetch_health(server.server_port,'/redirect','secret') + with self.assertRaises(ValueError): ops.fetch_health(server.server_port,'/large','secret') + timer=threading.Timer + started=time.monotonic() + with patch.object(ops.threading,'Timer',side_effect=lambda delay,action: timer(0.05,action)): + with self.assertRaises(ValueError): ops.fetch_health(server.server_port,'/slow','secret') + self.assertLess(time.monotonic()-started,0.3) + finally: + server.shutdown() + server.server_close() + worker.join() + + def test_installer_foreign_files_backup_and_versioning(self): + with tempfile.TemporaryDirectory() as root: + root=Path(root).resolve() + base=root/'base' + agents=root/'agents' + agents.mkdir(mode=0o700) + plist=agents/(ops.HIDE_LABEL+'.plist') + plist.write_bytes(b'') + with self.assertRaises(ValueError): + ops.install(base,root/'runtime',0,agents) + self.assertFalse(base.exists()) + plist.unlink() + def compiler(args, **kwargs): + Path(args[-1]).write_bytes(b'test-helper') + with patch.object(ops.subprocess,'run',side_effect=compiler): + ops.install(base,root/'runtime',0,agents) + (base/'relay.env').write_text('preserve') + ops.install(base,root/'runtime',0,agents) + self.assertEqual((base/'relay.env').read_text(),'preserve') + self.assertEqual(len(list(agents.glob('*.backup-*'))),2) + self.assertEqual(len(list((base/'ops-helpers').iterdir())),1) + binary=next((base/'ops-helpers').iterdir())/'hide-oliveyoung' + binary.chmod(0o600) + with self.assertRaises(ValueError): + ops.install(base,root/'runtime',0,agents) + binary.chmod(0o700) + helper=next((base/'ops-helpers').iterdir())/'macos-ops.py' + helper.write_text('tampered') + with self.assertRaises(ValueError): + ops.install(base,root/'runtime',0,agents) + +if __name__ == '__main__': unittest.main() diff --git a/tests/scripts/macos-ops.test.ts b/tests/scripts/macos-ops.test.ts new file mode 100644 index 00000000..5e10b9c8 --- /dev/null +++ b/tests/scripts/macos-ops.test.ts @@ -0,0 +1,11 @@ +import { execFileSync } from 'node:child_process'; +import { expect, it } from 'vitest'; + +it.skipIf(process.platform === 'win32')('Mac 운영 도구는 개인 파일과 안전한 상태만 관리한다', () => { + const output = execFileSync( + process.platform === 'darwin' ? '/usr/bin/python3' : 'python3', + ['tests/scripts/fixtures/macos_ops_tests.py'], + { encoding: 'utf8' }, + ); + expect(output).toBe(''); +}); diff --git a/tests/scripts/mcp-smoke-expansion.test.ts b/tests/scripts/mcp-smoke-expansion.test.ts new file mode 100644 index 00000000..b41d170f --- /dev/null +++ b/tests/scripts/mcp-smoke-expansion.test.ts @@ -0,0 +1,194 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import { MCP_SMOKE_SCENARIOS, runMcpSmoke } from '../../scripts/ops/mcp-smoke.js'; +import { CLI_SMOKE_COMMANDS } from '../../scripts/ops/cli-smoke.js'; +import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; + +const scenario = (name: string) => MCP_SMOKE_SCENARIOS.find((s) => s.toolName === name)!; +import { inventoryPayloads } from './mcp-smoke-fixtures.js'; +const stores = [{ storeCode: 's1', storeName: '강남점' }]; + +const invoke = async (service: string, payload: unknown) => { + const callTool = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text', text: JSON.stringify(payload) }] }); + const errors: string[] = []; + const code = await runMcpSmoke({ + service, + createClient: async () => ({ + listTools: async () => ({ tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })) }), + callTool, + close: async () => undefined, + }), + writeOut: () => undefined, + writeErr: (message) => errors.push(message), + }); + return { code, callTool, errors }; +}; + +describe('전체 서비스 공개 MCP smoke 확장', () => { + it('CLI가 지원하는 모든 서비스가 실제 MCP 시나리오와 workflow에 포함된다', () => { + const services = [...new Set(CLI_SMOKE_COMMANDS.map((command) => command.service))].sort(); + expect([...new Set(MCP_SMOKE_SCENARIOS.map((s) => s.service))].sort()).toEqual(services); + const workflow = readFileSync('.github/workflows/external-smoke.yml', 'utf8'); + for (const variable of ['CLI_SMOKE_SERVICES', 'MCP_SMOKE_SERVICES']) { + expect( + workflow + .match(new RegExp(`${variable}: (.+)`))![1] + .split(' ') + .sort(), + ).toEqual(services); + } + }); + + it.each(Object.keys(inventoryPayloads))('%s는 실제 재고 증거와 정상 품절을 검증한다', (name) => { + const check = scenario(name)?.validate; + expect(check).toBeTypeOf('function'); + expect(check?.(inventoryPayloads[name])).toBeNull(); + for (const payload of [ + {}, + { keyword: '커피' }, + { ...inventoryPayloads[name], inventory: {} }, + ]) { + expect(check?.(payload)).toBeTypeOf('string'); + } + }); + + it.each([ + ['gs25_check_inventory', 'realStockQuantity'], + ['seveneleven_check_inventory', 'stockQuantity'], + ['emart24_check_inventory', 'bizQty'], + ])('%s는 미확인, 누락, 음수, 문자열 수량을 거절한다', (name, field) => { + for (const quantity of [null, undefined, -1, '0', NaN]) { + const payload = structuredClone(inventoryPayloads[name]); + const inventory = payload.inventory as { stores: Record[] }; + inventory.stores[0][field] = quantity; + expect(scenario(name)?.validate(payload)).toBeTypeOf('string'); + } + }); + + it('CU 상품 검색과 매장 목록만으로 재고 성공을 판정하지 않는다', () => { + for (const stock of [null, undefined, -1, '0']) { + const payload = structuredClone(inventoryPayloads.cu_check_inventory); + const nearby = payload.nearbyStores as { stores: Record[] }; + nearby.stores[0].stock = stock; + expect(scenario('cu_check_inventory')?.validate(payload)).toBeTypeOf('string'); + } + }); + + it('올리브영 전역 재고와 미확인 매장 재고를 거절한다', () => { + const check = scenario('oliveyoung_check_inventory')?.validate; + const payload = structuredClone(inventoryPayloads.oliveyoung_check_inventory); + const inventory = payload.inventory as { + stockCheckedCount: number; + products: Record[]; + }; + inventory.stockCheckedCount = 0; + expect(check?.(payload)).toBeTypeOf('string'); + inventory.stockCheckedCount = 1; + inventory.products[0].stockSource = 'global_search'; + expect(check?.(payload)).toBeTypeOf('string'); + inventory.products[0].stockSource = 'nearby_store'; + inventory.products[0].storeInventory = { + stores: [{ ...stores[0], remainQuantity: null, stockStatus: 'unknown' }], + }; + expect(check?.(payload)).toBeTypeOf('string'); + }); + + it('올리브영은 매장 일반 수량 또는 픽업 수량의 실제 증거를 인정한다', () => { + const check = scenario('oliveyoung_check_inventory').validate; + const payload = structuredClone(inventoryPayloads.oliveyoung_check_inventory); + const inventory = payload.inventory as { + products: { storeInventory: { stores: unknown[] } }[]; + }; + inventory.products[0].storeInventory.stores = [ + { ...stores[0], remainQuantity: null, o2oRemainQuantity: 0, stockStatus: 'out_of_stock' }, + { ...stores[0], remainQuantity: null, o2oRemainQuantity: null, stockStatus: 'unknown' }, + ]; + expect(check(payload)).toBeNull(); + inventory.products[0].storeInventory.stores = [ + { ...stores[0], remainQuantity: 0, stockStatus: 'unknown' }, + ]; + expect(check(payload)).toBeTypeOf('string'); + inventory.products[0].storeInventory.stores = [ + { ...stores[0], remainQuantity: null, stockStatus: 'out_of_stock' }, + ]; + expect(check(payload)).toBeTypeOf('string'); + }); + + it('재고 집계가 실제 매장 및 상품과 다르면 실패한다', () => { + for (const name of Object.keys(inventoryPayloads)) { + const payload = structuredClone(inventoryPayloads[name]); + const inventory = payload.inventory as Record; + if (name === 'cu_check_inventory') { + (payload.nearbyStores as Record).count = 2; + } else if (name === 'oliveyoung_check_inventory') { + inventory.stockCheckedCount = 2; + } else { + inventory.count = 2; + } + expect(scenario(name).validate(payload)).toBeTypeOf('string'); + } + const cu = structuredClone(inventoryPayloads.cu_check_inventory); + (cu.nearbyStores as Record).stockItemCode = 'different'; + expect(scenario('cu_check_inventory').validate(cu)).toBeTypeOf('string'); + }); + + it.each(['megabox', 'lottecinema', 'cgv'])('%s 극장 검색은 빈 결과를 거절한다', (service) => { + const entry = MCP_SMOKE_SCENARIOS.find((s) => s.service === service)!; + expect(entry).toBeDefined(); + const idKey = service === 'cgv' ? 'theaterCode' : 'theaterId'; + expect(entry?.validate({ theaters: [{ [idKey]: '1', theaterName: '강남' }] })).toBeNull(); + for (const theaters of [undefined, [], [{}], [{ [idKey]: '1' }]]) { + expect(entry?.validate({ theaters })).toBeTypeOf('string'); + } + expect(entry?.args.limit).toBe(1); + }); + + it('디트릭스는 한 극장의 실제 상영작을 조회한다', () => { + const entry = scenario('dtryx_list_now_showing'); + expect(entry?.args).toMatchObject({ cinemaCode: '000067', includePlayDates: false }); + expect(entry?.validate({ movies: [{ movieCode: '1', movieName: '영화' }] })).toBeNull(); + for (const movies of [undefined, [], [{}], [{ movieCode: '1' }]]) { + expect(entry?.validate({ movies })).toBeTypeOf('string'); + } + }); + + it.each(['cu', 'oliveyoung', 'megabox', 'lottecinema', 'cgv', 'dtryx'])( + '%s는 공개 compact 도구를 실제 호출한다', + async (service) => { + const entry = MCP_SMOKE_SCENARIOS.find((s) => s.service === service)!; + const group = COMPACT_CATALOG.find((g) => g.routes.some((r) => r.legacy === entry?.toolName)); + const payload = + inventoryPayloads[entry?.toolName] ?? + (service === 'dtryx' + ? { movies: [{ movieCode: '1', movieName: '영화' }] } + : { theaters: [{ theaterId: '1', theaterCode: '1', theaterName: '강남' }] }); + const { code, callTool } = await invoke(service, payload); + expect(code).toBe(0); + expect(callTool).toHaveBeenCalledOnce(); + expect(callTool.mock.calls[0][0].name).toBe(group?.name); + expect(PUBLIC_TOOL_NAMES).toContain(callTool.mock.calls[0][0].name); + }, + ); + + it.each(['gs25', 'emart24'])( + '%s 검색 상품 코드가 없으면 후속 재고 호출을 하지 않는다', + async (service) => { + const { code, callTool, errors } = await invoke(service, { + keyword: service === 'gs25' ? '콜라' : '커피', + products: [], + }); + expect(code).toBe(1); + expect(callTool).toHaveBeenCalledOnce(); + expect(errors.join(' ')).toContain('검색한 상품 코드가 없습니다'); + }, + ); + + it('실패 진단은 실제 compact 도구와 시나리오 ID를 구분한다', async () => { + const { errors } = await invoke('daiso', {}); + expect(errors.join('\n')).toContain('tool=check_inventory'); + expect(errors.join('\n')).toContain('scenario=daiso_find_inventory_by_name'); + expect(errors.join('\n')).toContain('"operation":"by_name"'); + }); +}); diff --git a/tests/scripts/mcp-smoke-fixtures.ts b/tests/scripts/mcp-smoke-fixtures.ts new file mode 100644 index 00000000..73aa182c --- /dev/null +++ b/tests/scripts/mcp-smoke-fixtures.ts @@ -0,0 +1,47 @@ +const stores = [{ storeCode: 's1', storeName: '강남점' }]; +export const inventoryPayloads: Record> = { + gs25_check_inventory: { + keyword: '콜라', + itemCode: 'p1', + inventory: { count: 1, stores: [{ ...stores[0], realStockQuantity: 0 }] }, + }, + seveneleven_check_inventory: { + keyword: '커피', + product: { itemCode: 'p1', itemName: '커피' }, + stockAvailable: true, + inventory: { count: 1, stores: [{ ...stores[0], stockQuantity: 0 }] }, + }, + emart24_check_inventory: { + keyword: '커피', + pluCd: 'p1', + inventory: { count: 1, stores: [{ bizNo: 's1', storeName: '강남점', bizQty: 0 }] }, + }, + cu_check_inventory: { + keyword: '커피', + inventory: { available: true, items: [{ itemCode: 'p1', itemName: '커피' }] }, + nearbyStores: { + available: true, + stockItemCode: 'p1', + stockItemName: '커피', + count: 1, + stores: [{ ...stores[0], stock: 0 }], + }, + }, + oliveyoung_check_inventory: { + keyword: '선크림', + inventory: { + stockCheckedCount: 1, + products: [ + { + goodsNumber: 'p1', + goodsName: '선크림', + stockSource: 'nearby_store', + stockStatus: 'out_of_stock', + storeInventory: { + stores: [{ ...stores[0], remainQuantity: 0, stockStatus: 'out_of_stock' }], + }, + }, + ], + }, + }, +}; diff --git a/tests/scripts/mcp-smoke.test.ts b/tests/scripts/mcp-smoke.test.ts index ebbabf52..a19dac85 100644 --- a/tests/scripts/mcp-smoke.test.ts +++ b/tests/scripts/mcp-smoke.test.ts @@ -1,3 +1,4 @@ +import { inventoryPayloads } from './mcp-smoke-fixtures.js'; import { COMPACT_CATALOG, PUBLIC_TOOL_NAMES } from '../../src/core/compact/catalog.js'; /** * MCP smoke 스크립트 테스트 @@ -30,6 +31,11 @@ describe('runMcpSmoke', () => { 'seveneleven_get_catalog_snapshot', 'emart24_search_products', 'opinet_get_average_prices', + ...Object.keys(inventoryPayloads), + 'megabox_find_nearby_theaters', + 'lottecinema_find_nearby_theaters', + 'cgv_find_theaters', + 'dtryx_list_now_showing', ]); }); @@ -45,6 +51,15 @@ describe('runMcpSmoke', () => { 'seveneleven', 'emart24', 'opinet', + 'gs25', + 'seveneleven', + 'emart24', + 'cu', + 'oliveyoung', + 'megabox', + 'lottecinema', + 'cgv', + 'dtryx', ]); }); @@ -94,7 +109,14 @@ describe('runMcpSmoke', () => { country: 'JP', stores: [{ id: '0001', name: '渋谷店', brand: 'DAISO' }], }, - gs25_search_products: { keyword: '콜라', products: [{}] }, + ...inventoryPayloads, + megabox_find_nearby_theaters: { theaters: [{ theaterId: '1', theaterName: '강남' }] }, + lottecinema_find_nearby_theaters: { + theaters: [{ theaterId: '1', theaterName: '잠실' }], + }, + cgv_find_theaters: { theaters: [{ theaterCode: '1', theaterName: '강남' }] }, + dtryx_list_now_showing: { movies: [{ movieCode: '1', movieName: '영화' }] }, + gs25_search_products: { keyword: '콜라', products: [{ itemCode: 'p1' }] }, seveneleven_get_search_popwords: { available: false, count: 0, keywords: [] }, seveneleven_get_catalog_snapshot: { pages: { totalCount: 1, items: [{ itemCode: '1', itemName: '상품' }] }, @@ -102,7 +124,7 @@ describe('runMcpSmoke', () => { exhibitions: { totalCount: 0, items: [] }, }, seveneleven_search_products: { query: '커피', products: [{}] }, - emart24_search_products: { keyword: '커피', products: [{}] }, + emart24_search_products: { keyword: '커피', products: [{ pluCd: 'p1' }] }, opinet_get_average_prices: { provider: 'opinet', prices: [] }, }; @@ -125,6 +147,16 @@ describe('runMcpSmoke', () => { name: 'search_products', arguments: expect.objectContaining({ service: 'gs25', keyword: '콜라' }), }); + for (const [service, field] of [ + ['gs25', 'itemCode'], + ['emart24', 'pluCd'], + ]) { + expect(callTool).toHaveBeenCalledWith({ + name: 'check_inventory', + arguments: expect.objectContaining({ service, [field]: 'p1', storeLimit: 1 }), + }); + } + expect(callTool.mock.calls.every(([call]) => PUBLIC_TOOL_NAMES.includes(call.name))).toBe(true); expect(close).toHaveBeenCalledOnce(); }); @@ -133,9 +165,19 @@ describe('runMcpSmoke', () => { const listTools = vi.fn().mockResolvedValue({ tools: PUBLIC_TOOL_NAMES.map((name) => ({ name })), }); - const callTool = vi.fn().mockResolvedValue({ - content: [{ type: 'text', text: JSON.stringify({ keyword: '콜라', products: [{}] }) }], - }); + const callTool = vi + .fn() + .mockResolvedValueOnce({ + content: [ + { + type: 'text', + text: JSON.stringify({ keyword: '콜라', products: [{ itemCode: 'p1' }] }), + }, + ], + }) + .mockResolvedValueOnce({ + content: [{ type: 'text', text: JSON.stringify(inventoryPayloads.gs25_check_inventory) }], + }); const exitCode = await runMcpSmoke({ service: 'gs25', @@ -145,7 +187,7 @@ describe('runMcpSmoke', () => { }); expect(exitCode).toBe(0); - expect(callTool).toHaveBeenCalledOnce(); + expect(callTool).toHaveBeenCalledTimes(2); expect(callTool).toHaveBeenCalledWith({ name: 'search_products', arguments: expect.objectContaining({ service: 'gs25', keyword: '콜라' }), @@ -172,7 +214,7 @@ describe('runMcpSmoke', () => { }); expect(exitCode).toBe(1); - expect(errors.join('\n')).toContain('tool=daiso_find_inventory_by_name'); + expect(errors.join('\n')).toContain('scenario=daiso_find_inventory_by_name'); expect(errors.join('\n')).toContain('"query":"수납박스"'); expect(errors.join('\n')).toContain('responseExcerpt='); expect(close).toHaveBeenCalledOnce(); diff --git a/tests/scripts/repository-config.test.ts b/tests/scripts/repository-config.test.ts index ce93d750..c935a45b 100644 --- a/tests/scripts/repository-config.test.ts +++ b/tests/scripts/repository-config.test.ts @@ -142,10 +142,10 @@ describe('repository maintenance configuration', () => { expect(workflow).toContain("node-version: '24'"); expect(workflow.match(/npm run build/g)).toHaveLength(1); expect(workflow).toContain( - 'CLI_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet', + 'CLI_SMOKE_SERVICES: daiso daiso_jp cu gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx', ); expect(workflow).toContain( - 'MCP_SMOKE_SERVICES: daiso daiso_jp gs25 seveneleven emart24 opinet', + 'MCP_SMOKE_SERVICES: daiso daiso_jp cu gs25 seveneleven emart24 oliveyoung megabox lottecinema cgv opinet dtryx', ); expect(workflow).not.toContain('lottemart'); expect(workflow).toContain('SMOKE_ATTEMPT_TIMEOUT_SECONDS: 90');