From b2a2d3b00983212906a3c173cc5f462937ed207d Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 21 Sep 2026 10:38:06 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20CU=C2=B7CGV=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EA=B3=BC=20=EC=98=AC=EB=A6=AC=EB=B8=8C=EC=98=81=20Worker=20?= =?UTF-8?q?=ED=98=B8=ED=99=98=EC=84=B1=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cgv-network-analysis-result.md | 29 +++++ ...seveneleven-transport-recovery-20260921.md | 29 +++++ docs/oliveyoung-free-relay.md | 18 ++- .../plans/2026-09-21-cgv-recovery.md | 37 +++++++ .../plans/2026-09-21-cu-recovery.md | 43 ++++++++ .../plans/2026-09-21-dtryx-recovery.md | 103 ++++++++++++++++++ .../plans/2026-09-21-oliveyoung-recovery.md | 44 ++++++++ .../plans/2026-09-21-recovery-validation.md | 23 ++++ src/services/cgv/transport.ts | 2 + src/services/cu/client.ts | 2 + src/services/oliveyoung/transport.ts | 10 +- tests/app/app-mcp-client.test.ts | 2 +- tests/relay/ownership.test.ts | 4 +- tests/relay/process-snapshot.test.ts | 24 ++++ tests/scripts/relay-install-security.test.ts | 4 +- tests/scripts/relay-install.test.ts | 2 +- tests/services/cgv/transport.test.ts | 15 +++ tests/services/cu/client.test.ts | 1 + tests/services/oliveyoung/transport.test.ts | 31 +++++- tests/workflows/health-summary.test.ts | 2 +- 20 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 docs/gs25-seveneleven-transport-recovery-20260921.md create mode 100644 docs/superpowers/plans/2026-09-21-cgv-recovery.md create mode 100644 docs/superpowers/plans/2026-09-21-cu-recovery.md create mode 100644 docs/superpowers/plans/2026-09-21-dtryx-recovery.md create mode 100644 docs/superpowers/plans/2026-09-21-oliveyoung-recovery.md create mode 100644 docs/superpowers/plans/2026-09-21-recovery-validation.md create mode 100644 tests/relay/process-snapshot.test.ts diff --git a/docs/cgv-network-analysis-result.md b/docs/cgv-network-analysis-result.md index a8ddb8b5..65639a88 100644 --- a/docs/cgv-network-analysis-result.md +++ b/docs/cgv-network-analysis-result.md @@ -1,5 +1,34 @@ # CGV 네트워크 분석 결과 (실측 업데이트) +## 직접 호출 복구 (2026-09-21, KST) + +현재 비용 정책에서는 Zyte 유료 호출을 사용하지 않습니다. 아래 과거 기록의 Zyte fallback 전략은 현재 구현 지침이 아닙니다. + +### 원인과 최소 수정 + +기존 서명 헤더로 극장 목록을 호출하면 Cloudflare HTML 403이 발생했습니다. 같은 서명과 URL에서 헤더만 바꾼 통제 실험 결과는 다음과 같습니다. + +| 추가 헤더 | 실측 결과 | +| --- | --- | +| 없음 | HTTP 403 HTML | +| Chrome 140의 전체 User-Agent, Origin, Referer | HTTP 200 JSON | +| Chrome 140의 전체 User-Agent만 | HTTP 200, statusCode 0, 9개 지역 / 176개 극장 | +| Origin, Referer만 | HTTP 403 HTML | +| 일반 `Mozilla/5.0` User-Agent만 | HTTP 403 HTML | + +기존 HMAC 키와 서명 규칙은 그대로 승인되었습니다. 확인된 원인은 기본 fetch User-Agent에 민감한 upstream 필터이며, `transport.ts`에 실측한 전체 브라우저 User-Agent만 추가했습니다. 서명·엔드포인트·차단 오류 처리 및 유료 호출 비활성화 정책은 유지합니다. 헤더의 특정 토큰까지 분해해 필터 규칙을 추측하지는 않았습니다. + +### 검증 + +- 신규 회귀 테스트가 수정 전 HTTP 403으로 실패하는 것을 확인한 뒤 구현했습니다. +- `npx vitest run tests/services/cgv`: 8개 파일, 87개 테스트 통과. +- 수정된 실제 클라이언트 함수의 로컬 직접 호출: 극장 176개, 강남(`0056`) 영화 9개, 2026-09-21 시간표 29개. +- `wrangler dev --remote` Cloudflare 미리보기에서 수정된 앱 경로 호출: `/api/cgv/theaters?limit=3`, `/api/cgv/movies?theaterCode=0056&playDate=20260921`, `/api/cgv/timetable?theaterCode=0056&playDate=20260921&limit=3` 모두 HTTP 200, 비어 있지 않은 결과 확인. +- Cloudflare 미리보기에서도 User-Agent를 잠시 제거한 대조군은 원본 HTTP 403 / 앱 HTTP 503, 복원한 실험군은 HTTP 200 / 극장 3개로 확인했습니다. 경로의 24시간 edge cache 영향을 배제하기 위해 매 요청에 서로 다른 `probe` 쿼리를 사용했습니다. 처음 동일 URL로 비교한 캐시 응답은 원인 판단에서 제외했습니다. +- 미리보기 성공은 해당 Cloudflare 실행 환경의 근거입니다. 실제 운영 배포나 GitHub Actions 실행은 이 조사에서 수행하지 않았으므로 모든 IP의 접근을 보장하지 않습니다. + +--- + 작성일: 2026-03-04 (KST) 대상: - `https://www.cgv.co.kr` diff --git a/docs/gs25-seveneleven-transport-recovery-20260921.md b/docs/gs25-seveneleven-transport-recovery-20260921.md new file mode 100644 index 00000000..401bb4c2 --- /dev/null +++ b/docs/gs25-seveneleven-transport-recovery-20260921.md @@ -0,0 +1,29 @@ +# GS25·세븐일레븐 직접 호출 추가 조사 + +조사일: 2026-09-21 (KST). 유료 프록시 호출이나 운영 배포 없이 제한된 요청으로 확인했습니다. + +## 세븐일레븐 + +공식 웹 `https://new.7-elevenapp.co.kr/`은 로컬에서 기존 Android User-Agent로 HTTP 200이며, Incapsula 스크립트를 포함합니다. 여기서 참조하는 최신 공식 번들 `https://static.7-elevenapp.co.kr/k7app/_nuxt/app.js`를 읽었습니다. + +`useCustomFetch`는 GET 요청에 params, 그 외 요청에 body를 사용하며 `credentials: "include"`를 지정합니다. 확인한 공통 래퍼에 새로운 필수 고정 헤더를 추가하는 코드는 없었습니다. 이는 별도 쿠키 초기화 필요성을 확인할 근거이지만, 인증이 불필요한 모든 엔드포인트를 보장하지는 않습니다. + +동일한 Cloudflare 원격 미리보기 Worker(HKG)에서 다음 세션 초기화 대조를 수행했습니다. + +1. 기존 API 헤더로 동일 호스트 첫 페이지 GET: HTTP 403, Set-Cookie 2개. +2. 쿠키를 같은 Worker 요청 내에서만 보관해 공개 상품 검색 POST에 전달: HTTP 403, `Request unsuccessful` HTML. + +쿠키 값은 출력하거나 저장하지 않았습니다. 첫 페이지 초기화 자체가 차단되므로 이 절차는 데이터 경로를 복구하지 못했습니다. 부모 조사에서 기존 User-Agent, 전체 데스크톱 User-Agent, Origin·Referer 조합 역시 원격 403이었습니다. 새 헤더나 API 경로 변경을 뒷받침하는 증거가 없어 서비스 코드는 변경하지 않았습니다. + +## GS25 + +기존 상품 검색 요청 계약은 `POST https://b2c-apigw.woodongs.com/search/v3/totalSearch`, JSON `{query}`, 전체 Android User-Agent 및 기존 Origin·Referer입니다. + +동일한 공개 앱 경로 `/api/gs25/products?keyword=%EC%BD%9C%EB%9D%BC`에 서로 다른 `probe` 값을 붙여 캐시를 우회했습니다. + +| 실행 환경 | 결과 | +| --- | --- | +| 현재 코드 Cloudflare 원격 미리보기 | HTTP 200, 상품 20개 | +| 운영 `https://mcp.aka.page` | HTTP 500, `GS25_PRODUCT_SEARCH_FAILED`, 원본 HTTP 403 CloudFront HTML | + +환경 차이를 재현했지만 운영과 미리보기의 정확한 차단 기준은 확인되지 않았습니다. 현재 요청 계약이 미리보기에서 실데이터를 반환하므로 서명·헤더 변경으로 운영 차단을 해결할 수 있다고 주장하지 않습니다. 운영 복구를 검증한 코드 변경은 없습니다. diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index 2e1f6379..7256d77c 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -1,6 +1,22 @@ # 올리브영 무료 브라우저 릴레이 -2026-09-15 검증: 이 Mac의 일반 HTTP 요청은 올리브영 검색 API에서 HTTP 403 보안 검증 HTML을 받았습니다. 개인 프로필과 분리한 **화면 있는 Chromium**에서는 네 API 모두 HTTP 200, `status: SUCCESS`였습니다. 새 headless 브라우저는 보안 검증 화면에 머물렀습니다. 따라서 GUI 로그인 세션, 브라우저, 전원과 네트워크를 유지할 수 있는 운영 호스트가 필요합니다. 설정만 추가했다고 복구된 것으로 판단하면 안 됩니다. +## 2026-09-21 Windows 로컬 검증 + +Windows 11의 설치된 Chrome을 별도 임시 컨텍스트로 실행하여 기존 `createBrowserRunner`와 `createOliveyoungRelay`를 검증했습니다. 개인 프로필·로그인 정보는 사용하지 않았습니다. Node 클라이언트가 `127.0.0.1`의 임시 포트에 생성한 인증 릴레이를 거쳐 브라우저에서 조회하는 전체 경로를 확인했습니다. + +- 상품 검색: 선크림 399개, 상품 번호 `A000000263510`. +- 매장 검색: 강남 기준 50개. +- 상품 상세에서 실제 `masterGoodsNumber`를 구한 뒤 매장별 재고 조회: `checkedCount: 1`, `stockSource: nearby_store`. +- 매장별 재고: 50개 중 재고 있음 47개. 수량은 조회 시점 값입니다. +- 실행 종료 시 임시 HTTP 서버와 소유한 브라우저를 닫았습니다. 인증 토큰은 메모리에서 생성했으며 출력하거나 저장하지 않았습니다. + +이 검증은 Windows에서도 브라우저 조회와 로컬 HTTP 릴레이를 시험할 수 있음을 보여 줍니다. 기존 `start.ts`의 상시 프로세스 감시와 설치 도구는 POSIX/macOS용이므로 Windows 상시 운영 지원을 의미하지 않습니다. 공개 Worker에서 이 PC로 연결되는 터널이나 자동 실행은 구성하지 않았습니다. + +2026-09-15 검증: 이 Mac의 일반 HTTP 요청은 올리브영 검색 API에서 HTTP 403 보안 검증 HTML을 받았습니다. 개인 프로필과 분리한 **화면 있는 Chromium**에서는 네 API 모두 HTTP 200, `status: SUCCESS`였습니다. 새 headless 브라우저는 보안 검증 화면에 머물렀습니다. 이 브라우저 릴레이를 사용하려면 GUI 로그인 세션, 브라우저, 전원과 네트워크를 유지할 수 있는 운영 호스트가 필요합니다. 설정만 추가했다고 복구된 것으로 판단하면 안 됩니다. + +2026-09-21 Cloudflare 원격 프리뷰에서는 직접 요청의 런타임 오류를 수정하고 고정 브라우저 헤더를 추가해 상품·매장 조회의 실제 성공을 확인했습니다. 다만 같은 요청에서도 HTTP 403이 간헐적으로 발생하고 상품 상세 재고 API는 계속 실패했습니다. 상세 매장 재고까지 복구되었거나 릴레이 호스트가 더 이상 필요하지 않다고 판단하지 않습니다. 설정된 릴레이는 계속 우선 사용합니다. + +Workers가 지원하지 않는 `redirect: 'error'` 대신 `manual`을 사용하며, HTTP 200만 허용하는 검사로 리다이렉트를 거절합니다. 토큰을 다른 주소로 따라 보내지 않습니다. User-Agent·Origin·Referer·Accept-Language는 공식 API 직접 요청에만 고정값으로 추가하며 릴레이에는 전달하지 않습니다. [조사 및 실제 검증 기록](superpowers/plans/2026-09-21-oliveyoung-recovery.md)을 참고하세요. ## 실행 diff --git a/docs/superpowers/plans/2026-09-21-cgv-recovery.md b/docs/superpowers/plans/2026-09-21-cgv-recovery.md new file mode 100644 index 00000000..a5913c6e --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-cgv-recovery.md @@ -0,0 +1,37 @@ +# CGV direct transport recovery implementation plan + +**Goal:** Restore free direct CGV requests where the upstream accepts browser request headers. + +**Architecture:** Keep the existing endpoint, HMAC signing, normalization and explicit upstream errors. Add only the browser User-Agent proven necessary by controlled live requests. Do not add paid proxy calls or return empty success for failures. + +**Tech stack:** TypeScript, fetch, Web Crypto, Vitest. + +## Evidence + +2026-09-21 local Windows network, `GET https://api.cgv.co.kr/cnm/atkt/searchRegnList?coCd=A420`: + +| Request variation | Result | +| --- | --- | +| Existing signed headers | Cloudflare HTTP 403 HTML | +| Same signature + full Chrome User-Agent + Origin + Referer | HTTP 200 JSON | +| Same signature + full Chrome User-Agent only | HTTP 200, statusCode 0, 9 regions, 176 theaters | +| Same signature + Origin + Referer only | HTTP 403 HTML | +| Same signature + generic Mozilla/5.0 only | HTTP 403 HTML | + +The existing signing key and message format are accepted. This isolates the local failure to User-Agent-sensitive upstream filtering. It does not establish that Cloudflare Workers or GitHub Actions IPs are accepted with the same header. + +## Tasks + +- [x] Reproduce the failure and isolate the request-header variable with bounded direct requests. +- [x] Add a regression test to `tests/services/cgv/transport.test.ts` asserting the observed required header and successful JSON handling. Run it and verify it fails before implementation. +- [x] Add the measured browser User-Agent to `src/services/cgv/transport.ts` without changing signing or error handling. +- [x] Run `npx vitest run tests/services/cgv` and repeat direct theater/movie/timetable calls using production functions. +- [x] Record results and remaining environment limitations in `docs/cgv-network-analysis-result.md`. + +No commits, pushes or deployments are part of this delegated change. + +## Verification outcome + +Regression test failed with HTTP 403 before the change. All 87 CGV tests then passed. Local production functions returned 176 theaters, 9 movies and 29 timetable entries. Cloudflare remote preview returned HTTP 200 and nonempty data for all three CGV app routes. No production deployment was performed. + +Controlled Cloudflare remote preview test with unique `probe` query parameters (to avoid edge cache): temporarily removing User-Agent produced upstream HTTP 403 / app HTTP 503; restoring User-Agent produced HTTP 200 with 3 theaters. The initial same-URL cached response was excluded from this comparison. The source was restored and the 14 transport tests passed again. diff --git a/docs/superpowers/plans/2026-09-21-cu-recovery.md b/docs/superpowers/plans/2026-09-21-cu-recovery.md new file mode 100644 index 00000000..31903483 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-cu-recovery.md @@ -0,0 +1,43 @@ +# CU 매장 조회 복구 조사 및 구현 계획 + +## 범위 + +- `src/services/cu/**`, `tests/services/cu/**` 및 CU 조사 문서만 수정한다. +- 무료 공식 웹 조회를 조사한다. 유료 폴백이나 오류를 빈 성공으로 바꾸는 처리는 추가하지 않는다. + +## 확인한 사실 + +2026-09-21 로컬 Node 환경에서 인증 없이 15초 제한으로 확인했다. + +- 변경 전 `fetchCuStores({ searchWord: '강남' })`: 실제 매장 5개 반환. +- 공식 페이지 `https://cu.bgfretail.com/store/list.do?category=store`: HTTP 200. +- 공식 페이지의 `searchList(pageNo)`는 `type: "post"`, `/store/list_Ajax.do`, `$('#listForm').serialize()`를 사용한다. +- 공식 폼에는 현재 코드의 검색 필드와 빈 `user_id` 필드가 있다. +- 현재 클라이언트의 전체 헤더와 폼을 유지한 GET/POST 비교: 둘 다 HTTP 200, 동일 매장 코드 `39418`, `50212`, `48893`, `50615`, `51006` 반환. +- 따라서 GET이 공식 호출 방식이라는 기존 주석은 잘못되었지만, 메서드만으로 운영 HTTP 400의 원인이라고 결론 내릴 수 없다. +- 변경 전 CU 테스트: 4개 파일, 52개 테스트 통과. + +## 다음 단계 + +1. Cloudflare 실행 환경에서 동일 GET/POST를 비교해 로컬 환경과의 차이를 확인한다. +2. 성공하는 요청 계약이 확인되면 해당 회귀 테스트를 먼저 작성하고 예상 실패를 확인한다. +3. 입증된 최소 수정만 적용하고 CU 테스트 및 실제 조회를 다시 실행한다. +4. 원격 환경에서도 모두 실패하면 메서드 변경을 복구로 주장하지 않고 제약과 증거를 기록한다. + +## Cloudflare 원격 비교 결과 + +- 임시 `wrangler dev --remote` 프리뷰의 `/api/cu/stores?keyword=강남&limit=1`에서 변경 전 GET은 upstream HTTP 400을 반환했다. +- POST 계약 회귀 테스트를 먼저 수정하여 예상한 GET/POST 불일치 실패를 확인했다. +- 메서드와 폼 위치만 POST로 바꾼 임시 후보도 원격 요청 두 번 모두 같은 upstream HTTP 400을 반환했다. +- POST 복구 가설은 원격 비교에서 입증되지 않았다. 임시 소스와 테스트 변경은 모두 되돌렸다. +- 로컬에서 GET/POST가 모두 실제 매장을 반환하므로, 실행 환경에 따른 상위 서버 응답 차이가 남아 있다. 이것만으로 특정 차단 장비나 IP 정책을 확정할 수 없다. + +## 원인 확인과 최종 수정 + +- 웹 전용 헤더에는 `User-Agent`가 명시되어 있지 않았다. Node와 Workers의 기본 헤더 차이를 가설로 세웠다. +- 헤더 계약 테스트에 `User-Agent: Mozilla/5.0` 기대값을 추가하고 기존 구현에서 실패함을 확인했다. +- 메서드는 기존 GET 그대로 두고 해당 헤더 하나만 추가했다. 원격 프리뷰가 HTTP 200과 실제 매장 5개로 바뀌었다. +- 헤더를 다시 제거하고 캐시를 피한 새 요청으로 upstream 400 재현을 확인했다. +- 헤더를 다시 추가한 새 요청은 다시 HTTP 200, 매장 코드 `39418`, 총 5개를 반환했다. 이 A/B/A 비교로 이번 원격 오류가 User-Agent 명시 여부에 달려 있음을 확인했다. +- 최종 수정은 웹 요청의 명시적 User-Agent와 회귀 테스트뿐이다. GET/POST 전환이나 재시도, 유료 폴백은 추가하지 않았다. +- 최종 CU 테스트: 4개 파일, 52개 통과. 프로덕션 배포는 수행하지 않았으며 운영 배포 후 동일 조회 확인이 필요하다. diff --git a/docs/superpowers/plans/2026-09-21-dtryx-recovery.md b/docs/superpowers/plans/2026-09-21-dtryx-recovery.md new file mode 100644 index 00000000..634a775e --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-dtryx-recovery.md @@ -0,0 +1,103 @@ +# Dtryx 복구 조사 — 2026-09-21 + +## 목표와 범위 + +#187의 배포 Worker 시간 초과를 재현하고, 전송 경로와 클라이언트 제한 시간 처리를 구분한다. 근거가 확인된 변경만 구현하고 먼저 실패하는 회귀 테스트를 작성한다. 서비스 소스·테스트와 이 조사 기록만 수정하며 커밋·배포·유료 API 호출은 하지 않는다. + +## 조사 순서 + +1. #187, #188, #190 및 클라이언트의 호출·본문 읽기·취소 경로를 확인한다. +2. 동일 극장과 엔드포인트로 로컬 30443/443, 배포 Worker를 제한된 횟수로 비교한다. +3. 가능하면 임시 원격 Worker에서 네트워크 단계별 진단을 비교한다. +4. 원인이 확인되면 최소 변경을 TDD로 구현한다. 확인되지 않으면 미해결 조건과 후속 진단을 명시한다. + +## 확인된 사실 + +- [#187](https://github.com/hmmhmmhm/daiso-mcp/issues/187): 같은 단일 극장이 성공과 실패를 반복하며 배포 경로의 상영작 조회 오류는 시간 초과다. 전 극장 실패를 성공 200으로 바꾸면 실제 실패를 가릴 수 있다. +- [#188](https://github.com/hmmhmmhm/daiso-mcp/pull/188): 443으로 바꾸자는 제안이며 작성자도 Worker 복구 효과는 검증하지 못했다고 명시했다. +- [#190](https://github.com/hmmhmmhm/daiso-mcp/pull/190): 동일 조건의 443 Worker 시험 두 번 모두 시간 초과였으므로 #188을 포함하지 않았다. +- 클라이언트는 요청당 AbortController를 만들고 `response.json()`까지 기다린 다음 `finally`에서 타이머를 해제한다. 따라서 헤더를 받자마자 제한 시간이 사라지는 결함은 없다. 기본값은 15초이며 재시도는 없다. +- `includePlayDates`의 두 요청과 여러 극장의 요청은 병렬 실행한다. 한 극장 상영작 호출만으로도 문제가 발생하므로 다중 극장 동시성만으로 설명되지 않는다. +- [Cloudflare 공식 문서](https://developers.cloudflare.com/workers/configuration/compatibility-flags/#allow-specifying-a-custom-port-when-making-a-subrequest-with-the-fetch-api)는 2024-09-02부터 `allow_custom_ports`가 기본이라고 설명한다. Cloudflare 프록시를 쓰지 않는 호스트에는 임의 포트가 허용된다. 따라서 일반적인 Cloudflare 프록시 지원 포트 표만으로 30443 차단을 단정할 수 없다. + +## 실측 + +2026-09-21, Windows 로컬 Node fetch에서 각 회차마다 아래 세 경로를 동시에 호출하고 세 회차는 순서대로 실행했다. 대상은 `spacedog / 000072`, `third-party-type2-movie-now`이며 클라이언트 외부 제한은 8초다. 배포 API에는 `timeoutMs=5000`을 전달했다. + +| 경로 | 1회(ms) | 2회(ms) | 3회(ms) | 결과 | +| --- | ---: | ---: | ---: | --- | +| api.dtryx.com:30443 | 415 | 248 | 280 | 3/3 HTTP 200, success, 34건 | +| api.dtryx.com:443 | 281 | 232 | 247 | 3/3 HTTP 200, success, 34건 | +| mcp.aka.page/api/dtryx/movies | 5658 | 5287 | 5392 | 0/3, HTTP 500 DTRYX_NOW_SHOWING_FAILED, 디트릭스 API 호출 시간 초과 | + +로컬 DNS는 A `222.234.220.30` 한 개를 반환했으며 AAAA 주소는 반환하지 않았다. 별도 curl 실측도 두 포트 HTTP 200이었다. + +| 포트 | TCP 연결(s) | TLS 완료(s) | 첫 바이트(s) | 전체(s) | +| --- | ---: | ---: | ---: | ---: | +| 30443 | 0.072 | 0.345 | 0.427 | 0.484 | +| 443 | 0.065 | 0.154 | 0.203 | 0.246 | + +## 현재 판단과 제한 + +로컬 원본 API는 응답하지만 배포 경로에서 제한 시간이 소진되는 현상까지 확인했다. DNS, TCP, TLS, HTTP 헤더, 본문 중 Worker의 어느 단계가 멈추는지는 아직 알 수 없다. 포트 변경과 재시도가 문제를 해결한다는 근거는 없다. 임의의 포트 교체나 기본 시간 증가, 전체 실패의 성공 응답 변환은 적용하지 않는다. + +임시 원격 Worker 진단 결과는 후속 기록한다. 로컬 단위 테스트 통과는 배포 전송 경로 복구를 증명하지 않는다. + +## 원격 Worker 비교 결과 + +부모 작업에서 `wrangler dev --remote`로 띄운 임시 전체 앱(`127.0.0.1:8789`로 전달, 프로덕션 변경 없음)을 사용했다. 동일 상영작 요청, `timeoutMs=5000`, 외부 제한 10초를 유지했다. + +| 임시 원격 Worker 원본 포트 | 1회(ms) | 2회(ms) | 결과 | +| --- | ---: | ---: | --- | +| 현재 30443 | 6095 | 5163 | 0/2, HTTP 500 시간 초과 | +| 일시 교체한 443 | 5603 | 5181 | 0/2, HTTP 500 시간 초과 | + +443 변경은 되돌렸다. 이 실험은 #190의 결과를 다시 관측했으며, 포트 변경을 복구책으로 채택할 근거를 얻지 못했다. 이어서 fetch 시작과 헤더 도착에만 임시 로그를 넣은 한 번의 요청도 5696ms 후 시간 초과였다. 진단 로그는 실행 후 제거했다. + +`git diff -- src/services/dtryx`는 비어 있고, `npm test -- tests/services/dtryx`는 6개 파일 94개 테스트가 통과했다. 동작 변경을 남기지 않았으므로 새 회귀 테스트를 추가하지 않았다. 복구 여부는 미해결이며 이슈를 닫을 수 없다. + +## 다음 진단의 완료 조건 + +- Worker의 fetch 시작과 응답 헤더 수신 사이에서 멈추는지 로그로 확인한다. +- 헤더 이전 실패라면 업스트림 운영자의 TCP/TLS 또는 접근 로그와 요청 시각을 대조해야 DNS·라우팅·방화벽·TLS 중 원인을 구분할 수 있다. 로컬 성공만으로 원본 서비스가 Cloudflare 송신 주소에도 정상 응답한다고 단정할 수 없다. +- 수정 후보가 있다면 같은 원격 Worker에서 원래 경로와 후보 경로를 비교하여 유효한 JSON 성공을 확인하고, 기존 15초 전체 제한과 실패 응답 의미를 유지하는 최소 변경을 테스트부터 작성한다. +- 재시도는 현재의 연속 실패 표본에서 효과가 입증되지 않았고 단순 15초 재시도는 최대 응답 시간을 늘린다. 효과와 전체 제한 시간을 함께 검증하기 전 기본 동작에 넣지 않는다. + +## 추가 진단: 응답 헤더와 User-Agent + +부모 작업이 원격 로그에서 `DTRYX_DIAGNOSTIC_FETCH_START`만 있고 `DTRYX_DIAGNOSTIC_HEADERS`는 없음을 확인했다. 해당 요청은 응답 본문이 아니라 응답 헤더 수신 전에 멈췄다. + +다른 서비스에서 효과가 확인된 데스크톱 User-Agent만 임시로 추가해 비교했다. 최초 000072는 590ms에 200이 되었지만, 헤더를 제거한 같은 요청도 4회 연속 성공했다(242/549/105/97ms). 따라서 한 번의 성공만으로 User-Agent가 원인이라고 볼 수 없다. + +추가 극장 000067/000069는 헤더 없이 각각 5618/5118ms 시간 초과였고, 헤더 추가 후 987/222ms에 성공했다. 캐시 영향을 구분하려고 User-Agent를 다시 제거하고 `cache: no-store`만 임시 적용했을 때 000072/000067은 성공했다(911/140ms). 새 극장 000065도 666ms에 성공했지만 000088은 5109ms 후 시간 초과였다. 즉 제한된 표본에서는 헤더 변경, 시간대별 간헐성, 연결/캐시 상태를 분리하지 못했다. + +User-Agent와 no-store를 모두 원복했으며 서비스 소스의 최종 diff는 없다. 이 추가 실험도 영구 수정의 인과 근거로 사용하지 않는다. 원격 경로에서 성공과 헤더 이전 시간 초과가 모두 관측되므로 단순 포트 차단이나 항상 실패하는 URL 조립 결함으로 설명할 수 없다. + +## 최종 통제 실험: 캐시와 재업로드 영향 제거 + +앞선 전체 앱 비교는 `src/api/routes/dtryxRoutes.ts`의 `withEdgeCache`가 성공한 전체 URL을 캐시한다는 교란 요인이 있었다. 요청의 `Cache-Control`이나 내부 fetch의 no-store만으로 바깥 캐시가 사라지지 않는다. 소스 파일 저장 직후 원격 Worker 업로드가 완료되기 전에 요청하면 이전 변형이 실행될 가능성도 있다. 따라서 앞선 User-Agent A/B에서 성공 후 원복 성공을 인과 판단에 사용하지 않는다. + +이를 제거하려고 단일 임시 Worker 스크립트가 쿼리 `variant=original|ua`로 User-Agent 유무만 선택하게 했다. 30443, 극장 000072, WorkGuID, Accept는 동일하며 내부 `cache: no-store`, 5초 제한을 양쪽에 고정했다. 앱의 캐시 코드를 거치지 않고 매번 고유 `probe`도 추가했다. 실험 중 스크립트는 수정하지 않았다. + +| 순서 | User-Agent | 결과 | 원본 요청 시간(ms) | +| --- | --- | --- | ---: | +| A1 | 없음 | HTTP 200, success, 34건 | 295 | +| B1 | 데스크톱 Chrome 153 | HTTP 200, success, 34건 | 77 | +| A2 | 없음 | HTTP 200, success, 34건 | 70 | +| B2 | 데스크톱 Chrome 153 | 헤더 수신 전 TimeoutError | 5000 | + +**최종 판단:** User-Agent 없는 요청도 정상 응답하고 데스크톱 User-Agent 요청도 시간 초과된다. 따라서 CGV에서 효과가 확인된 User-Agent 수정을 Dtryx에 그대로 적용할 근거가 없다. 제한된 표본은 Worker 전송 경로의 간헐성을 보여주지만 네트워크 원인을 구분하지 못한다. 임시 서비스 소스 변경은 모두 제거했고 최종 산출물은 조사 기록이다. 업스트림 또는 Cloudflare 전송 로그와의 대조가 다음 진단 단계다. + +## 재시도 완화 가능성 확인 + +원인을 해결하지 못하더라도 같은 요청의 두 번째 시도가 회복되는지 추가로 확인했다. 먼저 같은 원본 조건을 네 번 순차 실행했으나 모두 헤더 수신 전 5초 시간 초과였다. + +이어 정적 임시 Worker의 `variant=retry`가 동일 요청 안에서 최대 두 번만 원본 GET을 실행하도록 했다. 각 시도는 5초, 합계 최대 10초이며 헤더 수신 후 오류는 재시도하지 않는다. 이 코드는 진단 전용이며 제품 소스에 추가하지 않았다. 세 번의 결과는 다음과 같다. + +| 진단 요청 | 첫 시도 | 둘째 시도 | 총 시간 | +| --- | --- | --- | --- | +| 1 | 헤더 전 시간 초과 5000ms | 헤더 전 시간 초과 5000ms | 10000ms | +| 2 | 헤더 전 시간 초과 5000ms | 헤더 전 시간 초과 5000ms | 10000ms | +| 3 | HTTP 200, 34건, 97ms | 실행하지 않음 | 97ms | + +이번 표본에는 첫 시도 실패가 다음 시도로 회복된 사례가 없었다. 제한 시간 일부를 잘라 재시도를 위한 예산을 확보하면 정상적으로 느린 응답까지 중단할 수 있으므로, 효과가 확인되지 않은 상태에서 기본 동작을 바꾸지 않는다. 향후 같은 요청에서 재시도 회복이 관측되면 원래 전체 제한 시간 안에서만 한 번 재시도하고, HTTP 오류·JSON 파싱·RetCode 오류는 그대로 보존하는 방안을 별도로 검증할 수 있다. diff --git a/docs/superpowers/plans/2026-09-21-oliveyoung-recovery.md b/docs/superpowers/plans/2026-09-21-oliveyoung-recovery.md new file mode 100644 index 00000000..bf6eb01f --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-oliveyoung-recovery.md @@ -0,0 +1,44 @@ +# 올리브영 직접 요청 복구 계획 + +## 조사 + +2026-09-21 임시 Cloudflare 원격 Worker에서 인증 없는 상품 검색을 비교했다. +기존 헤더는 반복한 세 요청 모두 403이었다. 데스크톱 User-Agent만 추가한 경우 +200과 403이 섞였다. User-Agent, Origin, Referer, Accept-Language 조합은 +최초 403 관측 후 연속 세 번 200/SUCCESS였으며 선크림 399개 중 실제 상품 +`A000000263510`이 반환됐다. 개별 헤더를 제거한 실험도 결과가 변동하므로 +특정 헤더 하나가 유일한 원인이라고 단정하지 않는다. + +기존 앱의 원격 프리뷰 `/api/oliveyoung/products?keyword=선크림&limit=1`도 +수정 전 직접 요청 실패를 반환했다. + +## 구현 및 검증 순서 + +1. 직접 요청에 확인한 헤더 조합을 기대하는 회귀 테스트를 추가하고 실패를 확인한다. +2. 릴레이를 설정하지 않은 직접 요청에만 고정 헤더를 추가한다. +3. 릴레이 URL 우선순위, 토큰 전달 범위, 리다이렉트 거부와 재시도 금지를 유지한다. +4. 원격 프리뷰에서 상품·매장·재고 API를 확인한다. 응답 변동 및 캐시 제약을 기록한다. +5. 서비스 테스트와 릴레이 보안 회귀 테스트를 실행한다. 운영 배포는 하지 않는다. + +## 구현 및 실제 결과 + +- 별도 원격 fetch 비교에서 `redirect: 'error'`는 Workers가 지원하지 않아 + `Invalid redirect value ... follow or manual` TypeError를 발생시키는 것을 확인했다. +- 해당 런타임 동작을 재현하는 실패 테스트를 작성한 뒤 `redirect: 'manual'`로 + 변경했다. 기존 `expectedStatus: 200` 검사로 3xx를 거절하며 리다이렉트를 따라가지 않는다. +- 실제 전송 모듈을 임시 원격 Worker에 직접 import해 앱 캐시 없이 비교했다. + manual 모드만 적용하고 추가 헤더를 제거하면 네 API 모두 403이었다. + 고정 헤더 조합을 복구하면 상품 검색 SUCCESS/399개, 매장 검색 SUCCESS/50개가 + 확인됐지만, 동일 조합에서도 간헐적인 403은 남았다. +- `stock-stores`: 실제 상품 ID `8809962545756`의 50개 매장 응답 확인. + 서래마을점 응답에는 remainQuantity 19, o2oRemainQuantity 15가 있었다. +- `stock-goods-info-v3`: 상품 번호 `A000000263510`의 반복 조회는 403이었다. + 따라서 상품별 근처 매장 재고 흐름의 완전한 복구를 확인하지 못했다. +- 전체 앱 원격 프리뷰는 상품/매장 HTTP 200을 반환했다. 재고 API도 후속 호출에서 + HTTP 200이었지만 `stockCheckedCount: 0`, `stockUncheckedCount: 1`, + `stockSource: global_search`였으므로 상세 재고 복구 증거로 보지 않는다. +- 외부 403 변동에 대응하는 추가 재시도나 캐시 확대는 하지 않았다. 설정된 릴레이 + 우선순위와 토큰 범위는 유지한다. 브라우저 호스트가 불필요하다고 결론 내리지 않는다. +- 최종 검증: 올리브영 서비스/API 핸들러/앱 라우트 테스트 9개 파일, 93개 통과. + 실제 로컬 HTTP 서버를 이용한 릴레이 리다이렉트 비밀 미전달 검사도 포함한다. + 변경한 TypeScript 파일의 ESLint 및 전체 `tsc --noEmit` 통과. diff --git a/docs/superpowers/plans/2026-09-21-recovery-validation.md b/docs/superpowers/plans/2026-09-21-recovery-validation.md new file mode 100644 index 00000000..b63043a6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-21-recovery-validation.md @@ -0,0 +1,23 @@ +# 서비스 복구 검증 계획 + +목표는 유료 대체 호출 없이 실제 조회를 복구하고, 운영 환경에서 효과를 확인한 변경만 반영하는 것입니다. + +## 설계와 범위 + +기존 전송 계층의 최소 수정, 기존 브라우저 릴레이 연결, 신규 중계 확장 세 경로를 비교합니다. 우선 공개 API의 공식 요청 계약과 실제 응답을 비교하여 최소 수정을 적용합니다. 릴레이는 실행 가능한 호스트와 인증 경로가 확인되어야 연결할 수 있습니다. 단순 재시도나 빈 결과로 장애를 숨기지 않습니다. + +CU와 CGV는 요청 헤더를 비교합니다. 디트릭스는 같은 요청의 로컬·Worker·포트별 응답을 비교합니다. GS25와 세븐일레븐은 로컬·Worker 차이를 기록합니다. 성공한 실측이 없는 후보는 반영하지 않습니다. 서비스별 세부 계획은 같은 폴더의 cu/cgv/dtryx-recovery 문서에 둡니다. + +## 검증 절차 + +- [x] 최신 main에서 worktree 생성, npm ci 및 수정 전 전체 테스트 실행. +- [x] 운영 및 비공개 Cloudflare remote preview에서 실패를 재현. +- [x] 서비스별 실패 테스트를 먼저 실행하고, 최소 수정 후 관련 테스트 재실행. +- [x] Windows에서 실패하는 POSIX 전용 설치·프로세스 통합 검사를 플랫폼에 맞게 분리. OS 스냅샷 파서와 명령 호출은 모의 프로세스 결과로 모든 플랫폼에서 검증. +- [x] workflow 테스트의 LF/CRLF 처리를 정규화. +- [x] npm run check, npm run test:coverage, npm run build, npm audit 실행. +- [ ] 독립 리뷰 후 draft PR 생성. 운영 반영이 승인될 때 검증된 변경과 남은 장애를 구분하여 fresh 점검. + +수정 전 결과는 scratch/daiso-recovery-baseline.log에 보존했습니다. POSIX /bin/ps, python3 및 pwd 의존성, CRLF 스크립트 추출 때문에 Windows에서 실패합니다. 타임아웃은 병렬 전체 실행 시 발생했으므로 개별 재실행 결과를 먼저 확인합니다. + +최종 로컬 검증: npm run check 통과, 1,963개 테스트 통과 및 POSIX 전용 4개 명시적 생략. 커버리지 Statements/Branches/Functions/Lines 모두 100%, build 성공, audit 취약점 0건입니다. 독립 리뷰에서 필수 수정 사항은 없었습니다. Windows에서는 임시 인증 HTTP 릴레이를 통해 실제 nearby_store 재고 조회까지 성공했습니다. diff --git a/src/services/cgv/transport.ts b/src/services/cgv/transport.ts index 2d6b3531..bf1eab8c 100644 --- a/src/services/cgv/transport.ts +++ b/src/services/cgv/transport.ts @@ -72,6 +72,8 @@ export async function requestCgv( headers: { Accept: 'application/json', 'Accept-Language': 'ko-KR', + // 기본 fetch User-Agent는 CGV 앞단에서 403으로 차단된다. + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36', 'X-TIMESTAMP': timestamp, 'X-SIGNATURE': signature, }, diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index c66976db..8a893f68 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -59,6 +59,8 @@ const CU_DEFAULT_HEADERS = { } as const; const CU_WEB_DEFAULT_HEADERS = { + // Workers의 기본 요청에는 User-Agent가 없어 공식 웹이 400을 반환합니다. + 'User-Agent': 'Mozilla/5.0', 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', Accept: 'text/html, */*; q=0.01', diff --git a/src/services/oliveyoung/transport.ts b/src/services/oliveyoung/transport.ts index c374456e..1347c981 100644 --- a/src/services/oliveyoung/transport.ts +++ b/src/services/oliveyoung/transport.ts @@ -59,12 +59,20 @@ export async function requestOliveyoung( headers['CF-Access-Client-Id'] = accessClientId; headers['CF-Access-Client-Secret'] = accessClientSecret; } + } else { + // 공식 사이트 직접 조회용 헤더는 운영자 릴레이에 전달하지 않습니다. + headers['User-Agent'] = + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36'; + headers.Origin = OLIVEYOUNG_API.BASE_URL; + headers.Referer = `${OLIVEYOUNG_API.BASE_URL}/`; + headers['Accept-Language'] = 'ko-KR,ko;q=0.9'; } let result: OliveyoungApiResponse; try { result = await fetchJson(url, { method: 'POST', - redirect: 'error', + // Workers는 error 모드를 지원하지 않으므로 따라가지 않고 아래 200 검사로 거절합니다. + redirect: 'manual', headers, body: JSON.stringify(body), timeout, diff --git a/tests/app/app-mcp-client.test.ts b/tests/app/app-mcp-client.test.ts index 48e08a87..c78a4697 100644 --- a/tests/app/app-mcp-client.test.ts +++ b/tests/app/app-mcp-client.test.ts @@ -152,6 +152,6 @@ it('MCP 올리브영 요청은 운영 바인딩의 Access 자격증명만 사용 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'})})); + expect(mockFetch).toHaveBeenCalledWith('https://relay.example/v1/oliveyoung/product-search-v3',expect.objectContaining({redirect:'manual',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/ownership.test.ts b/tests/relay/ownership.test.ts index 06c6f2cd..67055dc4 100644 --- a/tests/relay/ownership.test.ts +++ b/tests/relay/ownership.test.ts @@ -28,9 +28,11 @@ it('원래 그룹의 정체를 확인할 수 없으면 강제 종료하지 않 await expect(closeOwnedGroup(root, async () => {}, snapshot, kill)).rejects.toThrow('identity'); expect(kill).not.toHaveBeenCalled(); }); -it('실제 OS 스냅샷을 읽고 잘못된 형식과 이미 사라진 그룹을 처리한다', async () => { +it.skipIf(process.platform === 'win32')('실제 POSIX 스냅샷에서 현재 프로세스를 찾는다', async () => { const { processSnapshot } = await import('../../scripts/relay/ownership.js'); expect((await processSnapshot()).some((row) => row.pid === process.pid)).toBe(true); +}); +it('잘못된 형식과 이미 사라진 그룹을 처리한다', async () => { expect(() => parseProcesses('bad')).toThrow('Invalid'); const close = vi.fn().mockResolvedValue(undefined); await closeOwnedGroup(root, close, async () => [], vi.fn()); diff --git a/tests/relay/process-snapshot.test.ts b/tests/relay/process-snapshot.test.ts new file mode 100644 index 00000000..a29df609 --- /dev/null +++ b/tests/relay/process-snapshot.test.ts @@ -0,0 +1,24 @@ +/** POSIX 명령 호출과 결과 변환은 Windows에서도 검증합니다. */ +import { expect, it, vi } from 'vitest'; + +const { execFile } = vi.hoisted(() => ({ execFile: vi.fn() })); +vi.mock('node:child_process', () => ({ execFile })); + +import { processSnapshot } from '../../scripts/relay/ownership.js'; + +it('ps 명령의 제한 시간을 적용하고 메모리와 프로세스 정체를 읽는다', async () => { + execFile.mockImplementation((_file, _args, _options, callback) => { + callback(null, { + stdout: '100 1 100 200 Mon Sep 15 09:00:00 2026 Chrome', + }); + }); + expect(await processSnapshot()).toEqual([ + { pid: 100, ppid: 1, pgid: 100, rss: 204800, birth: 'Mon Sep 15 09:00:00 2026', command: 'Chrome' }, + ]); + expect(execFile).toHaveBeenCalledWith( + '/bin/ps', + ['-axo', 'pid=,ppid=,pgid=,rss=,lstart=,command='], + { timeout: 5000, maxBuffer: 4 * 1024 * 1024 }, + expect.any(Function), + ); +}); diff --git a/tests/scripts/relay-install-security.test.ts b/tests/scripts/relay-install-security.test.ts index c53c169c..ae988650 100644 --- a/tests/scripts/relay-install-security.test.ts +++ b/tests/scripts/relay-install-security.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { expect, it } from 'vitest'; -it('설치 원본의 외부·끊어진 링크를 거절하고 내부 링크만 허용한다',()=>{ +it.skipIf(process.platform === 'win32')('설치 원본의 외부·끊어진 링크를 거절하고 내부 링크만 허용한다',()=>{ const output=execFileSync(process.platform==='darwin'?'/usr/bin/python3':'python3',['-c',` import runpy,tempfile from pathlib import Path @@ -18,7 +18,7 @@ print('ok') `],{encoding:'utf8'}); expect(output.trim()).toBe('ok'); }); -it('설치 후속 단계가 실패하면 새 런타임과 비밀 파일을 회수한다',()=>{ +it.skipIf(process.platform === 'win32')('설치 후속 단계가 실패하면 새 런타임과 비밀 파일을 회수한다',()=>{ const output=execFileSync(process.platform==='darwin'?'/usr/bin/python3':'python3',['-c',` import importlib.util,tempfile,os,types from pathlib import Path diff --git a/tests/scripts/relay-install.test.ts b/tests/scripts/relay-install.test.ts index 181a4f5d..e52cdcff 100644 --- a/tests/scripts/relay-install.test.ts +++ b/tests/scripts/relay-install.test.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process'; import { expect, it } from 'vitest'; -it('macOS 설치 설정은 GUI 전용 계정과 제한된 Node 메모리·재시작을 사용한다',()=>{ +it.skipIf(process.platform === 'win32')('macOS 설치 설정은 GUI 전용 계정과 제한된 Node 메모리·재시작을 사용한다',()=>{ const output=execFileSync(process.platform === 'darwin' ? '/usr/bin/python3' : 'python3',['-c',` import runpy,json from pathlib import Path diff --git a/tests/services/cgv/transport.test.ts b/tests/services/cgv/transport.test.ts index d07bb4ff..3a0f6092 100644 --- a/tests/services/cgv/transport.test.ts +++ b/tests/services/cgv/transport.test.ts @@ -20,6 +20,21 @@ afterEach(() => { }); describe('requestCgv', () => { + it('CGV가 허용하는 브라우저 User-Agent로 직접 요청한다', async () => { + mockFetch.mockImplementationOnce((_url: string, init: RequestInit) => { + const userAgent = new Headers(init.headers).get('User-Agent'); + if (userAgent !== 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36') { + return Promise.resolve(new Response('blocked', { status: 403 })); + } + return Promise.resolve(Response.json({ statusCode: 0, data: [{ regnGrpCd: '01' }] })); + }); + + await expect( + requestCgv('/cnm/atkt/searchRegnList', new URLSearchParams({ coCd: 'A420' }), 1000), + ).resolves.toEqual({ statusCode: 0, data: [{ regnGrpCd: '01' }] }); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('정상 응답을 JSON으로 파싱한다', async () => { mockFetch.mockResolvedValueOnce( new Response(JSON.stringify({ statusCode: 0, data: [] }), { status: 200 }), diff --git a/tests/services/cu/client.test.ts b/tests/services/cu/client.test.ts index af2f59d4..afc76eec 100644 --- a/tests/services/cu/client.test.ts +++ b/tests/services/cu/client.test.ts @@ -79,6 +79,7 @@ describe('fetchCuStores', () => { headers: expect.objectContaining({ 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', 'X-Requested-With': 'XMLHttpRequest', + 'User-Agent': 'Mozilla/5.0', }), }), ); diff --git a/tests/services/oliveyoung/transport.test.ts b/tests/services/oliveyoung/transport.test.ts index 448408ea..443a57c6 100644 --- a/tests/services/oliveyoung/transport.test.ts +++ b/tests/services/oliveyoung/transport.test.ts @@ -2,6 +2,23 @@ import { afterEach, expect, it, vi } from 'vitest'; import { fetchOliveyoungStores } from '../../../src/services/oliveyoung/client.js'; const params = { latitude: 37.5, longitude: 127, pageIdx: 1, searchWords: '' }; afterEach(() => vi.unstubAllGlobals()); +it('Workers에서 지원하는 수동 리다이렉트 모드로 직접 요청한다', async () => { + const fetch = vi.fn(async (_url: string, options: RequestInit) => { + if (options.redirect === 'error') throw new TypeError('Invalid redirect value'); + return Response.json({ status: 'SUCCESS', data: {} }); + }); + vi.stubGlobal('fetch', fetch); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + await expect(requestOliveyoung('/p', {})).resolves.toMatchObject({ status: 'SUCCESS' }); + expect(fetch.mock.calls[0][1].redirect).toBe('manual'); +}); +it.each([301, 302, 303, 307, 308])('직접 요청의 HTTP %i는 SUCCESS 본문이어도 거절한다', async (status) => { + const fetch = vi.fn().mockResolvedValue(Response.json({ status: 'SUCCESS' }, { status })); + vi.stubGlobal('fetch', fetch); + const { requestOliveyoung } = await import('../../../src/services/oliveyoung/transport.js'); + await expect(requestOliveyoung('/p', {})).rejects.toThrow('직접 요청 실패'); + expect(fetch).toHaveBeenCalledTimes(1); +}); it('키 없이 공식 API JSON을 직접 읽고 Zyte를 호출하지 않는다', async () => { const fetch = vi .fn() @@ -11,6 +28,12 @@ it('키 없이 공식 API JSON을 직접 읽고 Zyte를 호출하지 않는다', expect(fetch.mock.calls[0][0]).toBe( 'https://www.oliveyoung.co.kr/oystore/api/storeFinder/find-store', ); + expect(fetch.mock.calls[0][1].headers).toMatchObject({ + 'User-Agent': expect.stringContaining('Mozilla/5.0'), + Origin: 'https://www.oliveyoung.co.kr', + Referer: 'https://www.oliveyoung.co.kr/', + 'Accept-Language': 'ko-KR,ko;q=0.9', + }); }); it('신뢰한 옵션의 릴레이를 우선 사용하고 토큰을 전달한다', async () => { const fetch = vi @@ -23,8 +46,14 @@ it('신뢰한 옵션의 릴레이를 우선 사용하고 토큰을 전달한다' ).toBe(8); expect(fetch).toHaveBeenCalledWith( 'https://relay.example/v1/oliveyoung/find-store', - expect.objectContaining({ headers: expect.objectContaining({ Authorization: 'Bearer test' }) }), + expect.objectContaining({ + redirect: 'manual', + headers: expect.objectContaining({ Authorization: 'Bearer test' }), + }), ); + for (const name of ['User-Agent', 'Origin', 'Referer', 'Accept-Language']) { + expect(fetch.mock.calls[0][1].headers).not.toHaveProperty(name); + } }); it('HTTP201의 SUCCESS 본문도 정상 응답으로 처리하지 않는다', async () => { vi.stubGlobal( diff --git a/tests/workflows/health-summary.test.ts b/tests/workflows/health-summary.test.ts index 01af4f8e..40c63304 100644 --- a/tests/workflows/health-summary.test.ts +++ b/tests/workflows/health-summary.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { runInNewContext } from 'node:vm'; import { describe, expect, it } from 'vitest'; -const workflow = readFileSync('.github/workflows/health-checks.yml', 'utf8'); +const workflow = readFileSync('.github/workflows/health-checks.yml', 'utf8').replace(/\r\n/g, '\n'); const script = workflow.split("node <<'NODE'\n")[1].split('\n NODE')[0]; describe('Health Checks 알림 요약', () => {