diff --git a/.github/workflows/sync-worker-secrets.yml b/.github/workflows/sync-worker-secrets.yml index d8f77d3f..ee7e9613 100644 --- a/.github/workflows/sync-worker-secrets.yml +++ b/.github/workflows/sync-worker-secrets.yml @@ -32,6 +32,10 @@ jobs: OY_RELAY_TOKEN: ${{ secrets.OY_RELAY_TOKEN }} OY_ACCESS_CLIENT_ID: ${{ secrets.OY_ACCESS_CLIENT_ID }} OY_ACCESS_CLIENT_SECRET: ${{ secrets.OY_ACCESS_CLIENT_SECRET }} + DTRYX_RELAY_URL: ${{ secrets.DTRYX_RELAY_URL }} + DTRYX_RELAY_TOKEN: ${{ secrets.DTRYX_RELAY_TOKEN }} + DTRYX_ACCESS_CLIENT_ID: ${{ secrets.DTRYX_ACCESS_CLIENT_ID }} + DTRYX_ACCESS_CLIENT_SECRET: ${{ secrets.DTRYX_ACCESS_CLIENT_SECRET }} GS25_API_KEY: ${{ secrets.GS25_API_KEY }} GOOGLE_MAPS_API_KEY: ${{ secrets.GOOGLE_MAPS_API_KEY }} NAVER_CLIENT_ID: ${{ secrets.NAVER_CLIENT_ID }} @@ -57,6 +61,10 @@ jobs: put_secret_if_set OY_RELAY_TOKEN "$OY_RELAY_TOKEN" put_secret_if_set OY_ACCESS_CLIENT_ID "$OY_ACCESS_CLIENT_ID" put_secret_if_set OY_ACCESS_CLIENT_SECRET "$OY_ACCESS_CLIENT_SECRET" + put_secret_if_set DTRYX_RELAY_URL "$DTRYX_RELAY_URL" + put_secret_if_set DTRYX_RELAY_TOKEN "$DTRYX_RELAY_TOKEN" + put_secret_if_set DTRYX_ACCESS_CLIENT_ID "$DTRYX_ACCESS_CLIENT_ID" + put_secret_if_set DTRYX_ACCESS_CLIENT_SECRET "$DTRYX_ACCESS_CLIENT_SECRET" put_secret_if_set GS25_API_KEY "$GS25_API_KEY" put_secret_if_set GOOGLE_MAPS_API_KEY "$GOOGLE_MAPS_API_KEY" put_secret_if_set NAVER_CLIENT_ID "$NAVER_CLIENT_ID" diff --git a/docs/dtryx-relay.md b/docs/dtryx-relay.md new file mode 100644 index 00000000..ee94deeb --- /dev/null +++ b/docs/dtryx-relay.md @@ -0,0 +1,55 @@ +# 디트릭스 Mac HTTPS 중계 + +## 구성 +Cloudflare에서 디트릭스로 나가는 연결이 실패할 때 Mac의 일반 HTTPS 요청을 사용한다. 브라우저·탭·Zyte는 사용하지 않는다. 원본 네트워크 장애를 고치는 것이 아니라 검증된 Mac 출발 경로를 이용하는 운영 구성이다. + +```text +MCP / REST → Cloudflare Worker + → Cloudflare Access + Tunnel + → 127.0.0.1:4320 디트릭스 프로세스 + → https://api.dtryx.com (공개 조회 GET) +``` + +Worker 설정이 없으면 기존 직접 요청을 유지한다. 설정이 있으면 중계만 사용하며 실패 뒤 원본 재시도나 유료 폴백은 하지 않는다. + +## Worker 설정 +- `DTRYX_RELAY_URL`: 중계 기본 HTTPS URL. 로컬 시험만 localhost/127.0.0.1 HTTP 허용. +- `DTRYX_RELAY_TOKEN`: 디트릭스 전용 Bearer 토큰. +- `DTRYX_ACCESS_CLIENT_ID`, `DTRYX_ACCESS_CLIENT_SECRET`: Access 서비스 토큰 쌍. + +Access 서비스 자격증명은 중계에만 보내고 원본 디트릭스에는 전달하지 않는다. 리다이렉트를 따라가지 않는다. `/health`의 `config.dtryxRelay`로 비밀값 없이 설정을 확인한다. GitHub 시크릿도 같은 이름으로 보관하고 Sync Worker Secrets 워크플로로 복구할 수 있다. + +## Mac 실행 +Node24와 프로젝트 의존성이 필요하다. 환경 파일은0600, 상태 디렉터리는0700으로 제한한다. + +```sh +node --max-old-space-size=128 --env-file=/absolute/path/dtryx.env \ + --import tsx scripts/relay/dtryx-start.ts +``` + +`dtryx.env`에는 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR` 절대 경로를 넣는다. `DTRYX_RELAY_PORT`는 생략 시4320이다. 설정 파일의 토큰을 버전 관리에 넣지 않는다. 올리브영과 다른 상태 디렉터리를 사용한다. + +현재 Mac 설치는 `~/Library/Application Support/DaisoRelay/dtryx-runtime`, `dtryx.env`, `dtryx-state`를 사용하고 `page.aka.daiso-dtryx` LaunchAgent로 관리한다. GUI 사용자 로그인 세션이 필요하다. Node 힙128MiB 제한은 전체 프로세스 RSS128MiB 보장이 아니다. + +## Tunnel 경로 +기존 `oy-relay.aka.page` Access 정책을 적용한 상태에서 기존 올리브영 규칙 앞에 다음 ingress를 배치한다. + +```json +{"hostname":"oy-relay.aka.page","path":"^/v1/dtryx/.*$","service":"http://127.0.0.1:4320"} +``` + +기존4319 올리브영 ingress 및 마지막404 규칙을 유지한다. 디트릭스 프로세스는 loopback에만 바인딩한다. Access 외에 핸들러 자체도 모든 경로에서 디트릭스 Bearer 토큰을 검증한다. + +## 허용 계약과 상한 +- `POST /v1/dtryx/timetable`: `brandCode`, `cinemaCode`, 유효한 `playDate`(YYYYMMDD). +- `POST /v1/dtryx/play-dates`, `POST /v1/dtryx/movies`: `brandCode`, `cinemaCode`. +- `GET /v1/dtryx/health`: 인증된 상태 조회. +- 임의URL·헤더·추가필드·알 수 없는 경로는 거절한다. +- 동시4개, 대기 포함32개, 전체15초 제한. 요청 본문16KiB, 원본 응답2MiB. +- 별도 파일 원장으로 분당30회·UTC일3000회를 제한한다. 재시작해도 유지되며 원장 장애는 차단한다. +- 성공은 원본의 `RetCode=success` 및 유효한 `Recordset`을 확인한 뒤 반환한다. 실패나 미확인 좌석을 정상 데이터로 만들지 않는다. + +## 점검과 복구 +먼저 로컬 인증 health와 세 API를 확인한 뒤 Access를 통한 같은 요청, 운영 REST/MCP를 확인한다. 올리브영의 health·실제 조회·탭 수를 함께 확인한다. 강제 종료 후 LaunchAgent 재시작 및 사용량 원장 보존을 확인한다. + +중계를 중단할 때는 Worker의 디트릭스 설정을 제거하면 직접 요청으로 돌아가지만, 원본 연결 장애가 재현될 수 있다. 토큰을 Worker/GitHub/로컬에서 함께 교체한다. 올리브영 토큰이나 상태 파일을 덮어쓰지 않는다. diff --git a/docs/superpowers/plans/2026-09-28-dtryx-relay.md b/docs/superpowers/plans/2026-09-28-dtryx-relay.md new file mode 100644 index 00000000..d9c3e864 --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-dtryx-relay.md @@ -0,0 +1,24 @@ +# 디트릭스 전용 HTTPS 중계 구현 계획 + +## 승인된 목표와 설계 +사용자가 Mac 출발 고정 API 중계를 승인했다. Cloudflare 원본 연결 실패를 피하기 위해 디트릭스만 별도 브라우저 없는 프로세스127.0.0.1:4320으로 중계한다. 기존 Tunnel/Access를 공유하며 토큰·프로세스·큐·파일 사용량 원장은 분리한다. 직접 CLI는 설정이 없으면 기존 원본 조회를 유지하고 Worker REST/MCP는 명시적 DTRYX 설정을 주입한다. 운영 변경 전 회귀·보안·수명주기 검증을 완료한다. + +## 경로 계약 +- POST /v1/dtryx/timetable: {brandCode, cinemaCode, playDate(YYYYMMDD)} +- POST /v1/dtryx/play-dates 및 /v1/dtryx/movies: {brandCode, cinemaCode} +- GET /v1/dtryx/health: 인증 상태 확인 +- 성공 응답은 기존 원본 RetCode/Recordset 그대로. 임의URL/헤더/추가필드/리다이렉트 불허. +- brandCode는 영문소문자로 시작하는 영문소문자/숫자/_/- 최대32자, cinemaCode는6자리 숫자, 날짜는 유효한 YYYYMMDD. +- 인증 Bearer 비교, 본문16KiB/응답2MiB/전체15초, 동시4·대기포함32개, 원장30회/분·3000회/UTC일. 취소·실패 뒤 슬롯 회수. +- DTRYX_RELAY_URL/TOKEN 및 DTRYX_ACCESS_CLIENT_ID/SECRET. 운영URL은 기존oy-relay.aka.page, 별도 Bearer토큰과 기존 Access서비스 자격증명 사용. + +## 실행 순서 +- [x] TDD로 Mac 핸들러/고정HTTPS 호출·상한·취소·별도 시작점 구현. +- [x] TDD로 client/options·REST·MCP·설정진단·시크릿동기화 연결. 원본 경로와 오류 의미 보존. +- [x] 독립 검토, 전체check/coverage100/build/audit/pack. +- [x] Mac LaunchAgent 설치·실제조회·인증거부·재시작 검증 후 Tunnel경로 추가. +- [ ] Worker/GitHub 시크릿 설정, PR CI/병합/배포/npm 릴리스. +- [ ] 운영 REST/MCP 반복 조회, OY 회귀·브라우저수 불변 확인. 결과와 한계 기록. + +## 운영 경계 +Zyte 미사용. OY브라우저코드/큐 변경 없음. 디트릭스 source3개 공개GET API만 호출. max-old-space-size128MiB, 요청/응답 상한, 외부신호종료시 HTTP연결 정리. 사용량원장은 재시작에도 유지하며 오류시failclosed. 원본 네트워크 문제 자체가 해결됐다고 주장하지 않는다. diff --git a/package-lock.json b/package-lock.json index 930cd0b4..a4efa12a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daiso", - "version": "1.2.4", + "version": "1.2.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daiso", - "version": "1.2.4", + "version": "1.2.5", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 80b280f7..2cf6d506 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daiso", - "version": "1.2.4", + "version": "1.2.5", "description": "Korean retail and cinema MCP server, CLI, and Codex Skill for Daiso, convenience stores, marts, Olive Young, and theaters", "main": "dist/index.js", "bin": { diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts new file mode 100644 index 00000000..ccf9d842 --- /dev/null +++ b/scripts/relay/dtryx-start.ts @@ -0,0 +1,65 @@ +/** 브라우저 없이 별도 프로세스로 디트릭스 중계를 실행합니다. */ +import { createServer } from 'node:http'; +import { Readable } from 'node:stream'; +import { mkdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createFileQuota } from './quota.js'; +import { createDtryxRelay } from './dtryx.js'; + +async function main() { + const token = process.env.DTRYX_RELAY_TOKEN; + const stateDir = process.env.DTRYX_RELAY_STATE_DIR; + const port = Number(process.env.DTRYX_RELAY_PORT || 4320); + if (!token?.trim() || !stateDir || !Number.isInteger(port) || port < 1024 || port > 65535) { + throw new Error('Invalid relay configuration'); + } + await mkdir(stateDir, { recursive: true, mode: 0o700 }); + const handler = createDtryxRelay(token, { + takeQuota: await createFileQuota(join(stateDir, 'quota.json')), + }); + const server = createServer(async (req, res) => { + const controller = new AbortController(); + res.once('close', () => controller.abort()); + try { + const response = await handler( + new Request(`http://127.0.0.1:${port}${req.url}`, { + method: req.method, + headers: req.headers as Record, + signal: controller.signal, + body: req.method === 'GET' || req.method === 'HEAD' ? undefined : Readable.toWeb(req), + duplex: 'half', + } as RequestInit & { duplex: 'half' }), + ); + res.writeHead(response.status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + Connection: 'close', + }); + res.end(await response.text()); + } catch { + res.writeHead(500, { Connection: 'close' }); + res.end('{"error":"Relay failure"}'); + } + }); + server.requestTimeout = 15000; + server.headersTimeout = 10000; + server.setTimeout(15000, (socket) => socket.destroy()); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(port, '127.0.0.1', resolve); + }); + console.log(`Dtryx relay ready on 127.0.0.1:${port}`); + const close = () => { + server.close(); + server.closeAllConnections(); + }; + process.once('SIGTERM', close); + process.once('SIGINT', close); +} +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch(() => { + console.error('디트릭스 릴레이 시작 실패: 토큰, 포트 및 상태 경로를 확인하세요.'); + process.exitCode = 1; + }); +} diff --git a/scripts/relay/dtryx.ts b/scripts/relay/dtryx.ts new file mode 100644 index 00000000..b8398222 --- /dev/null +++ b/scripts/relay/dtryx.ts @@ -0,0 +1,241 @@ +/** 고정 공개 API만 허용하는 디트릭스 전용 중계입니다. */ +import { timingSafeEqual } from 'node:crypto'; +import { DTRYX_API } from '../../src/services/dtryx/api.js'; + +type Route = 'timetable' | 'play-dates' | 'movies'; +interface Params { + brandCode: string; + cinemaCode: string; + playDate?: string; +} +interface Options { + takeQuota: () => Promise; + fetcher?: typeof fetch; +} +class RelayError extends Error { + constructor(readonly status: number) { + super('Relay failure'); + } +} +const reply = (status: number, body: unknown) => + Response.json(body, { + status, + headers: { 'Cache-Control': 'no-store' }, + }); + +/** 취소 신호를 무시하는 외부 처리도 기한 안에 반환합니다. */ +function abortable(task: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(new RelayError(499)); + task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + if (signal.aborted) { + reject(new RelayError(499)); + return; + } + signal.addEventListener('abort', abort, { once: true }); + }); +} + +async function readJson( + body: ReadableStream | null, + limit: number, + signal: AbortSignal, + status: number, +): Promise { + if (!body) throw new RelayError(status); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const { value, done } = await abortable(reader.read(), signal); + if (done) break; + bytes += value.byteLength; + if (bytes > limit) throw new RelayError(status === 400 ? 413 : status); + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (error) { + if (error instanceof RelayError) throw error; + throw new RelayError(status); + } finally { + void reader.cancel().catch(() => undefined); + } +} + +function validate(value: unknown, route: Route): Params { + if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400); + const p = value as Record; + const keys = + route === 'timetable' ? ['brandCode', 'cinemaCode', 'playDate'] : ['brandCode', 'cinemaCode']; + if ( + Object.keys(p).length !== keys.length || + !Object.keys(p).every((key) => keys.includes(key)) || + typeof p.brandCode !== 'string' || + !/^[a-z][a-z0-9_-]{0,31}$/.test(p.brandCode) || + typeof p.cinemaCode !== 'string' || + !/^\d{6}$/.test(p.cinemaCode) + ) + throw new RelayError(400); + if (route === 'timetable') { + if (typeof p.playDate !== 'string' || !/^\d{8}$/.test(p.playDate)) throw new RelayError(400); + const dashed = `${p.playDate.slice(0, 4)}-${p.playDate.slice(4, 6)}-${p.playDate.slice(6, 8)}`; + const time = Date.parse(`${dashed}T00:00:00Z`); + if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== dashed) + throw new RelayError(400); + } + return p as unknown as Params; +} + +async function upstream(route: Route, p: Params, signal: AbortSignal, fetcher: typeof fetch) { + const key = + route === 'timetable' ? 'TIMETABLE_LIST' : route === 'movies' ? 'MOVIE_NOW' : 'PLAY_DATE_LIST'; + const url = new URL( + `${DTRYX_API.THIRDPARTY_PATH}/${DTRYX_API.ENDPOINTS[key]}`, + DTRYX_API.BASE_URL, + ); + url.search = new URLSearchParams({ + ChannelCd: DTRYX_API.CHANNEL_CODE, + EngVerYn: 'N', + WorkGuID: DTRYX_API.WORK_GUIDS[key], + BrandCd: p.brandCode, + CinemaCd: p.cinemaCode, + }).toString(); + if (route === 'timetable') { + const date = p.playDate as string; + url.searchParams.set('PlaySDT', `${date.slice(0, 4)}-${date.slice(4, 6)}-${date.slice(6, 8)}`); + } + url.searchParams.set( + route === 'play-dates' ? 'MovieCd' : 'ImgSize', + route === 'play-dates' ? '' : 'small', + ); + const response = await abortable( + fetcher(url, { + method: 'GET', + redirect: 'manual', + signal, + headers: { Accept: 'application/json' }, + }), + signal, + ); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError(502); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + !('RetCode' in data) || + data.RetCode !== 'success' || + !('Recordset' in data) || + !Array.isArray(data.Recordset) || + !data.Recordset.every( + (item) => item !== null && typeof item === 'object' && !Array.isArray(item), + ) + ) { + throw new RelayError(502); + } + return data; +} + +export function createDtryxRelay(token: string, options: Options) { + if (!token.trim()) throw new Error('DTRYX_RELAY_TOKEN is required'); + const secret = Buffer.from(`Bearer ${token}`); + const fetcher = options.fetcher ?? fetch; + let outstanding = 0; + let active = 0; + const queue: Array<() => void> = []; + function acquire(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new RelayError(499)); + if (active < 4) { + active++; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const start = () => { + signal.removeEventListener('abort', abort); + active++; + resolve(); + }; + const abort = () => { + queue.splice(queue.indexOf(start), 1); + reject(new RelayError(499)); + }; + signal.addEventListener('abort', abort, { once: true }); + queue.push(start); + }); + } + return async (request: Request): Promise => { + const authorization = Buffer.from(request.headers.get('authorization') ?? ''); + if (authorization.length !== secret.length || !timingSafeEqual(authorization, secret)) + return reply(401, { error: 'Unauthorized' }); + const url = new URL(request.url); + if (url.pathname === '/v1/dtryx/health' && request.method === 'GET' && !url.search) + return reply(200, { status: 'ok', active, outstanding, queued: queue.length }); + const name = url.pathname.slice('/v1/dtryx/'.length); + if ( + !url.pathname.startsWith('/v1/dtryx/') || + !['timetable', 'play-dates', 'movies'].includes(name) || + url.search + ) + return reply(404, { error: 'Not found' }); + if (request.method !== 'POST') return reply(405, { error: 'Method not allowed' }); + if (outstanding >= 32) return reply(503, { error: 'Relay busy' }); + outstanding++; + const deadline = performance.now() + 15000; + const controller = new AbortController(); + const abort = () => controller.abort(); + request.signal.addEventListener('abort', abort, { once: true }); + if (request.signal.aborted) abort(); + let expired = false; + const timer = setTimeout(() => { + expired = true; + abort(); + }, 15000); + let acquired = false; + try { + const p = validate( + await readJson(request.body, 16384, controller.signal, 400), + name as Route, + ); + await acquire(controller.signal); + acquired = true; + if (performance.now() >= deadline) { + expired = true; + abort(); + } + controller.signal.throwIfAborted(); + if ( + !(await abortable( + options.takeQuota().catch(() => { + throw new RelayError(503); + }), + controller.signal, + )) + ) + throw new RelayError(429); + controller.signal.throwIfAborted(); + return reply(200, await upstream(name as Route, p, controller.signal, fetcher)); + } catch (error) { + const status = expired + ? 504 + : controller.signal.aborted + ? 499 + : error instanceof RelayError + ? error.status + : 502; + return reply(status, { error: 'Relay request failed' }); + } finally { + clearTimeout(timer); + request.signal.removeEventListener('abort', abort); + controller.abort(); + outstanding--; + if (acquired) { + active--; + queue.shift()?.(); + } + } + }; +} diff --git a/src/api/configStatus.ts b/src/api/configStatus.ts index 2d4e6ebe..96cf09fb 100644 --- a/src/api/configStatus.ts +++ b/src/api/configStatus.ts @@ -1,3 +1,4 @@ +import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; import { isValidOliveyoungRelayUrl } from '../services/oliveyoung/transport.js'; import type { AppBindings } from './response.js'; @@ -16,6 +17,7 @@ export interface ConfigStatus { accessConfigured: boolean; accessPairValid: boolean; }; + dtryxRelay: ConfigStatus['oliveyoungRelay']; googleMapsApiKey: ConfigStatusItem; zyteApiKey: ConfigStatusItem & { enabled: false }; naverLocalSearch: ConfigStatusItem; @@ -39,6 +41,20 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { bindings?.OY_ACCESS_CLIENT_SECRET === undefined) || accessConfigured; + const dtryxId = isConfigured(bindings?.DTRYX_ACCESS_CLIENT_ID); + const dtryxSecret = isConfigured(bindings?.DTRYX_ACCESS_CLIENT_SECRET); + const dtryx = { + urlValid: isValidDtryxRelayUrl(bindings?.DTRYX_RELAY_URL), + tokenConfigured: isConfigured(bindings?.DTRYX_RELAY_TOKEN), + accessClientIdConfigured: dtryxId, + accessClientSecretConfigured: dtryxSecret, + accessConfigured: dtryxId && dtryxSecret, + accessPairValid: + (bindings?.DTRYX_ACCESS_CLIENT_ID === undefined && + bindings?.DTRYX_ACCESS_CLIENT_SECRET === undefined) || + (dtryxId && dtryxSecret), + }; + return { oliveyoungRelay: { configured: urlValid && tokenConfigured && accessPairValid, @@ -51,6 +67,17 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { accessPairValid, usedBy: ['oliveyoung'], }, + dtryxRelay: { + configured: dtryx.urlValid && dtryx.tokenConfigured && dtryx.accessPairValid, + urlConfigured: isConfigured(bindings?.DTRYX_RELAY_URL), + urlValid: dtryx.urlValid, + tokenConfigured: dtryx.tokenConfigured, + accessClientIdConfigured: dtryx.accessClientIdConfigured, + accessClientSecretConfigured: dtryx.accessClientSecretConfigured, + accessConfigured: dtryx.accessConfigured, + accessPairValid: dtryx.accessPairValid, + usedBy: ['dtryx'], + }, googleMapsApiKey: { configured: isConfigured(bindings?.GOOGLE_MAPS_API_KEY), usedBy: ['gs25', 'cu', 'lottemart', 'megabox', 'lottecinema', 'cgv'], diff --git a/src/api/dtryxHandlers.ts b/src/api/dtryxHandlers.ts index fc197203..b34db264 100644 --- a/src/api/dtryxHandlers.ts +++ b/src/api/dtryxHandlers.ts @@ -2,6 +2,9 @@ * 디트릭스 GET API 핸들러 */ +import { ServiceError } from '../core/errors.js'; +import { dtryxTransportFromBindings } from '../services/dtryx/transport.js'; + import { resolveDtryxCinemas } from '../services/dtryx/location.js'; import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions } from '../services/dtryx/validation.js'; import { @@ -10,7 +13,12 @@ import { fetchDtryxTimetable, toYyyymmdd, } from '../services/dtryx/client.js'; -import { type ApiContext, errorResponse, successResponse } from './response.js'; +import { + type ApiContext, + errorResponse, + serviceErrorResponse, + successResponse, +} from './response.js'; export async function handleDtryxListCinemas(c: ApiContext) { const keyword = c.req.query('keyword') || undefined; @@ -66,8 +74,10 @@ export async function handleDtryxListNowShowing(c: ApiContext) { timeout: timeoutMs, }; const [movies, playDates] = await Promise.all([ - fetchDtryxNowShowing(request), - includePlayDates ? fetchDtryxPlayDates(request) : Promise.resolve([]), + fetchDtryxNowShowing(request, dtryxTransportFromBindings(c.env)), + includePlayDates + ? fetchDtryxPlayDates(request, dtryxTransportFromBindings(c.env)) + : Promise.resolve([]), ]); return successResponse( @@ -81,6 +91,7 @@ export async function handleDtryxListNowShowing(c: ApiContext) { { total: movies.length }, ); } catch (error) { + if (error instanceof ServiceError) return serviceErrorResponse(c, error, 'now_showing'); const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'; return errorResponse(c, 'DTRYX_NOW_SHOWING_FAILED', message, 500); } @@ -112,12 +123,15 @@ export async function handleDtryxGetRemainingSeats(c: ApiContext) { // Promise.allSettled 로 극장별 실패를 흡수하므로 여기서 예외가 전파되지 않습니다. const settled = await Promise.allSettled( cinemas.map((cinema) => - fetchDtryxTimetable({ - brandCode: cinema.brandCode, - cinemaCode: cinema.cinemaCode, - playDate, - timeout: timeoutMs, - }), + fetchDtryxTimetable( + { + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + playDate, + timeout: timeoutMs, + }, + dtryxTransportFromBindings(c.env), + ), ), ); const failedCinemas = cinemas diff --git a/src/api/response.ts b/src/api/response.ts index fbe399e3..9f9cb2ee 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -8,6 +8,10 @@ import { ServiceError, toServiceErrorDiagnostics, toStandardErrorDiagnostics } f export interface AppBindings { DAILY_RATE_LIMITER?: DurableObjectNamespace; ZYTE_API_KEY?: string; + DTRYX_RELAY_URL?: string; + DTRYX_RELAY_TOKEN?: string; + DTRYX_ACCESS_CLIENT_ID?: string; + DTRYX_ACCESS_CLIENT_SECRET?: string; OY_RELAY_URL?: string; OY_RELAY_TOKEN?: string; OY_ACCESS_CLIENT_ID?: string; diff --git a/src/index.ts b/src/index.ts index 34fe35f2..a13a0542 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,6 +5,8 @@ * 다이소, 편의점, 백화점 등 다양한 서비스를 확장할 수 있습니다. */ +import { dtryxTransportFromBindings } from './services/dtryx/transport.js'; + import { Hono, type Context } from 'hono'; import { cors } from 'hono/cors'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -100,7 +102,7 @@ const createRegistry = (bindings?: AppBindings) => { zyteApiKey: bindings?.ZYTE_API_KEY, }), createMegaboxService, - createDtryxService, + () => createDtryxService(dtryxTransportFromBindings(bindings)), () => createLotteCinemaService({ googleMapsApiKey: bindings?.GOOGLE_MAPS_API_KEY, diff --git a/src/services/dtryx/client.ts b/src/services/dtryx/client.ts index fb90d6b5..35914b4f 100644 --- a/src/services/dtryx/client.ts +++ b/src/services/dtryx/client.ts @@ -4,6 +4,11 @@ import { formatTime, toNumber, toYyyymmdd } from '../../utils/format.js'; import { createTimeoutController } from '../../utils/http.js'; +import { + hasDtryxRelayOptions, + requestDtryxRelay, + type DtryxTransportOptions, +} from './transport.js'; import { DTRYX_API } from './api.js'; import type { DtryxMovie, @@ -104,19 +109,33 @@ function buildScheduleId( } /** 상영 회차와 좌석 현황을 조회합니다. */ -export async function fetchDtryxTimetable(params: DtryxRequestParams): Promise { +export async function fetchDtryxTimetable( + params: DtryxRequestParams, + options: DtryxTransportOptions = {}, +): Promise { const playDate = params.playDate || toYyyymmdd(); - const response = await requestDtryx( - DTRYX_API.ENDPOINTS.TIMETABLE_LIST, - DTRYX_API.WORK_GUIDS.TIMETABLE_LIST, - { - BrandCd: params.brandCode, - CinemaCd: params.cinemaCode, - PlaySDT: toDashedDate(playDate), - ImgSize: 'small', - }, - params.timeout, - ); + const response = hasDtryxRelayOptions(options) + ? await requestDtryxRelay( + 'timetable', + { + brandCode: params.brandCode, + cinemaCode: params.cinemaCode, + playDate: toCompactDate(playDate), + }, + options, + params.timeout ?? DEFAULT_TIMEOUT_MS, + ) + : await requestDtryx( + DTRYX_API.ENDPOINTS.TIMETABLE_LIST, + DTRYX_API.WORK_GUIDS.TIMETABLE_LIST, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + PlaySDT: toDashedDate(playDate), + ImgSize: 'small', + }, + params.timeout, + ); return response.Recordset.filter((item) => item.CinemaCd && item.MovieCd && item.ScreenCd).map( (item) => { @@ -153,17 +172,25 @@ export async function fetchDtryxTimetable(params: DtryxRequestParams): Promise, + options: DtryxTransportOptions = {}, ): Promise { - const response = await requestDtryx( - DTRYX_API.ENDPOINTS.PLAY_DATE_LIST, - DTRYX_API.WORK_GUIDS.PLAY_DATE_LIST, - { - BrandCd: params.brandCode, - CinemaCd: params.cinemaCode, - MovieCd: '', - }, - params.timeout, - ); + const response = hasDtryxRelayOptions(options) + ? await requestDtryxRelay( + 'play-dates', + { brandCode: params.brandCode, cinemaCode: params.cinemaCode }, + options, + params.timeout ?? DEFAULT_TIMEOUT_MS, + ) + : await requestDtryx( + DTRYX_API.ENDPOINTS.PLAY_DATE_LIST, + DTRYX_API.WORK_GUIDS.PLAY_DATE_LIST, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + MovieCd: '', + }, + params.timeout, + ); return response.Recordset.filter((item) => item.PlaySDT).map((item) => ({ playDate: toCompactDate(item.PlaySDT as string), @@ -175,17 +202,25 @@ export async function fetchDtryxPlayDates( /** 현재 상영작 목록을 조회합니다. */ export async function fetchDtryxNowShowing( params: Omit, + options: DtryxTransportOptions = {}, ): Promise { - const response = await requestDtryx( - DTRYX_API.ENDPOINTS.MOVIE_NOW, - DTRYX_API.WORK_GUIDS.MOVIE_NOW, - { - BrandCd: params.brandCode, - CinemaCd: params.cinemaCode, - ImgSize: 'small', - }, - params.timeout, - ); + const response = hasDtryxRelayOptions(options) + ? await requestDtryxRelay( + 'movies', + { brandCode: params.brandCode, cinemaCode: params.cinemaCode }, + options, + params.timeout ?? DEFAULT_TIMEOUT_MS, + ) + : await requestDtryx( + DTRYX_API.ENDPOINTS.MOVIE_NOW, + DTRYX_API.WORK_GUIDS.MOVIE_NOW, + { + BrandCd: params.brandCode, + CinemaCd: params.cinemaCode, + ImgSize: 'small', + }, + params.timeout, + ); return response.Recordset.filter((item) => item.MovieCd && item.MovieNm).map((item) => ({ movieCode: String(item.MovieCd), diff --git a/src/services/dtryx/index.ts b/src/services/dtryx/index.ts index aaf308cc..31b0108f 100644 --- a/src/services/dtryx/index.ts +++ b/src/services/dtryx/index.ts @@ -4,6 +4,8 @@ * 국내 독립·예술영화관 다수가 공용으로 사용하는 예매 플랫폼을 조회합니다. */ +import type { DtryxTransportOptions } from './transport.js'; + import type { ServiceProvider } from '../../core/interfaces.js'; import type { ServiceMetadata, ToolRegistration } from '../../core/types.js'; import { createGetRemainingSeatsTool } from './tools/getRemainingSeats.js'; @@ -21,13 +23,19 @@ const DTRYX_METADATA: ServiceMetadata = { class DtryxService implements ServiceProvider { readonly metadata = DTRYX_METADATA; + constructor(private readonly options: DtryxTransportOptions) {} + getTools(): ToolRegistration[] { - return [createListCinemasTool(), createListNowShowingTool(), createGetRemainingSeatsTool()]; + return [ + createListCinemasTool(), + createListNowShowingTool(this.options), + createGetRemainingSeatsTool(this.options), + ]; } } -export function createDtryxService(): ServiceProvider { - return new DtryxService(); +export function createDtryxService(options: DtryxTransportOptions = {}): ServiceProvider { + return new DtryxService(options); } export * from './types.js'; diff --git a/src/services/dtryx/tools/getRemainingSeats.ts b/src/services/dtryx/tools/getRemainingSeats.ts index 0059d395..466eae11 100644 --- a/src/services/dtryx/tools/getRemainingSeats.ts +++ b/src/services/dtryx/tools/getRemainingSeats.ts @@ -2,6 +2,8 @@ * 디트릭스 상영시간표·잔여 좌석 조회 도구 */ +import type { DtryxTransportOptions } from '../transport.js'; + import * as z from 'zod'; import { DTRYX_INVALID_INPUT, @@ -35,7 +37,10 @@ function matchesMovie(showtime: DtryxShowtime, movieName?: string): boolean { return normalize(showtime.movieName).includes(normalize(movieName)); } -async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise { +async function getRemainingSeats( + args: GetRemainingSeatsArgs, + options: DtryxTransportOptions, +): Promise { if (hasInvalidDtryxOptions(args)) { return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] }; } @@ -64,12 +69,15 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise - fetchDtryxTimetable({ - brandCode: cinema.brandCode, - cinemaCode: cinema.cinemaCode, - playDate, - timeout: timeoutMs, - }), + fetchDtryxTimetable( + { + brandCode: cinema.brandCode, + cinemaCode: cinema.cinemaCode, + playDate, + timeout: timeoutMs, + }, + options, + ), ), ); @@ -125,7 +133,7 @@ async function getRemainingSeats(args: GetRemainingSeatsArgs): Promise getRemainingSeats(args as GetRemainingSeatsArgs)) as ( + handler: ((args) => getRemainingSeats(args as GetRemainingSeatsArgs, options)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/dtryx/tools/listNowShowing.ts b/src/services/dtryx/tools/listNowShowing.ts index d04b4a1c..29ae74b6 100644 --- a/src/services/dtryx/tools/listNowShowing.ts +++ b/src/services/dtryx/tools/listNowShowing.ts @@ -2,6 +2,8 @@ * 디트릭스 현재 상영작 조회 도구 */ +import type { DtryxTransportOptions } from '../transport.js'; + import * as z from 'zod'; import { DTRYX_INVALID_INPUT, hasInvalidDtryxOptions, dtryxTimeoutSchema } from '../validation.js'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; @@ -16,7 +18,10 @@ interface ListNowShowingArgs { timeoutMs?: number; } -async function listNowShowing(args: ListNowShowingArgs): Promise { +async function listNowShowing( + args: ListNowShowingArgs, + options: DtryxTransportOptions, +): Promise { if (hasInvalidDtryxOptions(args)) { return { isError: true, content: [{ type: 'text', text: DTRYX_INVALID_INPUT }] }; } @@ -49,8 +54,8 @@ async function listNowShowing(args: ListNowShowingArgs): Promise listNowShowing(args as ListNowShowingArgs)) as ( + handler: ((args) => listNowShowing(args as ListNowShowingArgs, options)) as ( args: unknown, ) => Promise, }; diff --git a/src/services/dtryx/transport.ts b/src/services/dtryx/transport.ts new file mode 100644 index 00000000..aa490842 --- /dev/null +++ b/src/services/dtryx/transport.ts @@ -0,0 +1,129 @@ +/** 디트릭스 전용 중계 설정과 인증 전송입니다. */ +import { ServiceError } from '../../core/errors.js'; +import { createTimeoutController } from '../../utils/http.js'; + +export interface DtryxTransportOptions { + relayUrl?: string; + relayToken?: string; + accessClientId?: string; + accessClientSecret?: string; +} + +export function isValidDtryxRelayUrl(value: string | undefined): boolean { + if (!value?.trim()) return false; + try { + const url = new URL(value); + return ( + !url.username && + !url.password && + !url.search && + !url.hash && + (url.protocol === 'https:' || + (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname))) + ); + } catch { + return false; + } +} + +/** 바인딩은 요청마다 변환하며 프로세스 전역에 저장하지 않습니다. */ +export function dtryxTransportFromBindings(bindings?: { + DTRYX_RELAY_URL?: string; + DTRYX_RELAY_TOKEN?: string; + DTRYX_ACCESS_CLIENT_ID?: string; + DTRYX_ACCESS_CLIENT_SECRET?: string; +}): DtryxTransportOptions { + return { + relayUrl: bindings?.DTRYX_RELAY_URL, + relayToken: bindings?.DTRYX_RELAY_TOKEN, + accessClientId: bindings?.DTRYX_ACCESS_CLIENT_ID, + accessClientSecret: bindings?.DTRYX_ACCESS_CLIENT_SECRET, + }; +} + +export function hasDtryxRelayOptions(options: DtryxTransportOptions): boolean { + return Object.values(options).some((value) => value !== undefined); +} + +export async function requestDtryxRelay( + path: 'movies' | 'play-dates' | 'timetable', + body: { brandCode: string; cinemaCode: string; playDate?: string }, + options: DtryxTransportOptions, + timeout: number, +): Promise { + const { relayUrl, relayToken, accessClientId, accessClientSecret } = options; + const accessPresent = accessClientId !== undefined || accessClientSecret !== undefined; + if ( + !isValidDtryxRelayUrl(relayUrl) || + !relayToken?.trim() || + (accessPresent && (!accessClientId?.trim() || !accessClientSecret?.trim())) + ) { + throw new ServiceError( + 'DTRYX_RELAY_CONFIG_ERROR', + '디트릭스 릴레이 URL, 토큰 및 Access 설정을 확인하세요.', + 503, + false, + ); + } + const headers: Record = { + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${relayToken}`, + }; + if (accessPresent) { + headers['CF-Access-Client-Id'] = accessClientId!; + headers['CF-Access-Client-Secret'] = accessClientSecret!; + } + const { controller, timeoutId } = createTimeoutController(timeout); + try { + // 리다이렉트를 따라 인증 정보를 다른 호스트에 전달하지 않습니다. + const response = await fetch(`${relayUrl!.replace(/\/$/, '')}/v1/dtryx/${path}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200) { + void response.body?.cancel().catch(() => {}); + const status = response.status; + const mapped = status === 429 || status === 503 || status === 504 ? status : 502; + throw new ServiceError( + 'DTRYX_RELAY_FAILED', + '디트릭스 릴레이 요청에 실패했습니다.', + mapped, + status === 429 || status >= 500, + status, + ); + } + const result: unknown = await response.json(); + if ( + typeof result !== 'object' || + result === null || + !('RetCode' in result) || + result.RetCode !== 'success' || + !('Recordset' in result) || + !Array.isArray(result.Recordset) || + !result.Recordset.every( + (item) => typeof item === 'object' && item !== null && !Array.isArray(item), + ) + ) { + throw new Error('응답 형식 오류'); + } + return result as T; + } catch (error) { + if (error instanceof ServiceError) throw error; + if (controller.signal.aborted) { + throw new ServiceError( + 'DTRYX_RELAY_TIMEOUT', + '디트릭스 릴레이 요청 시간이 초과되었습니다.', + 504, + true, + ); + } + throw new ServiceError('DTRYX_RELAY_FAILED', '디트릭스 릴레이 요청에 실패했습니다.', 502, true); + } finally { + clearTimeout(timeoutId); + controller.abort(); + } +} diff --git a/tests/api/dtryx-relay-integration.test.ts b/tests/api/dtryx-relay-integration.test.ts new file mode 100644 index 00000000..216fe2a0 --- /dev/null +++ b/tests/api/dtryx-relay-integration.test.ts @@ -0,0 +1,100 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { buildConfigStatus } from '../../src/api/configStatus.js'; +const env = { + DTRYX_RELAY_URL: 'https://relay.example', + DTRYX_RELAY_TOKEN: 'test-token', + DTRYX_ACCESS_CLIENT_ID: 'test-id', + DTRYX_ACCESS_CLIENT_SECRET: 'test-secret', +}; +afterEach(() => vi.unstubAllGlobals()); +it('REST가 영화, 날짜, 좌석 요청에 바인딩을 전달한다', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ RetCode: 'success', Recordset: [] })), + ); + vi.stubGlobal('fetch', fetchMock); + for (const path of [ + 'movies?cinemaCode=000067&includePlayDates=true', + 'seats?cinemaCode=000067&playDate=20260928', + ]) { + expect((await app.request('/api/dtryx/' + path, undefined, env)).status).toBe(200); + } + expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([ + 'https://relay.example/v1/dtryx/movies', + 'https://relay.example/v1/dtryx/play-dates', + 'https://relay.example/v1/dtryx/timetable', + ]); +}); +it('REST 중계 상태를 보존하고 비밀을 노출하지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('test-secret', { status: 429 }))); + const response = await app.request('/api/dtryx/movies?cinemaCode=000067', undefined, env); + expect(response.status).toBe(429); + expect(await response.text()).not.toContain('test-secret'); +}); +it('실제 MCP 호출이 세 경로에 중계를 사용한다', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ RetCode: 'success', Recordset: [] })), + ); + vi.stubGlobal('fetch', fetchMock); + const client = new Client({ name: 'test', version: '1' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { + fetch: async (url, init) => app.request(new Request(url, init), undefined, env), + }), + ); + try { + await client.callTool({ + name: 'dtryx_list_now_showing', + arguments: { cinemaCode: '000067', includePlayDates: true }, + }); + await client.callTool({ + name: 'dtryx_get_remaining_seats', + arguments: { cinemaCode: '000067' }, + }); + expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([ + 'https://relay.example/v1/dtryx/movies', + 'https://relay.example/v1/dtryx/play-dates', + 'https://relay.example/v1/dtryx/timetable', + ]); + fetchMock.mockResolvedValue(new Response('test-secret', { status: 503 })); + const failure = await client.callTool({ + name: 'dtryx_list_now_showing', + arguments: { cinemaCode: '000067' }, + }); + expect(failure.isError).toBe(true); + expect(JSON.stringify(failure)).toContain('503'); + expect(JSON.stringify(failure)).not.toContain('test-secret'); + } finally { + await client.close(); + } +}); +it('설정 진단은 값 없이 유효성만 반환한다', () => { + expect(buildConfigStatus(env).dtryxRelay).toMatchObject({ + configured: true, + accessPairValid: true, + }); + expect(buildConfigStatus().dtryxRelay).toMatchObject({ + configured: false, + accessPairValid: true, + }); + expect(buildConfigStatus({ ...env, DTRYX_ACCESS_CLIENT_SECRET: '' }).dtryxRelay).toMatchObject({ + configured: false, + accessPairValid: false, + }); + expect(JSON.stringify(buildConfigStatus(env))).not.toContain('test-secret'); +}); + +it('Access 설정은 양쪽이 함께 있어야 한다', () => { + expect( + buildConfigStatus({ ...env, DTRYX_ACCESS_CLIENT_ID: undefined }).dtryxRelay.accessPairValid, + ).toBe(false); + expect(buildConfigStatus({ DTRYX_RELAY_URL: env.DTRYX_RELAY_URL }).dtryxRelay.configured).toBe( + false, + ); +}); diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index acca4373..dfbea5fb 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -304,6 +304,17 @@ describe('기본 페이지', () => { accessPairValid: true, usedBy: ['oliveyoung'], }, + dtryxRelay: { + configured: false, + urlConfigured: false, + urlValid: false, + tokenConfigured: false, + accessClientIdConfigured: false, + accessClientSecretConfigured: false, + accessConfigured: false, + accessPairValid: true, + usedBy: ['dtryx'], + }, googleMapsApiKey: { configured: true, usedBy: expect.arrayContaining(['gs25', 'cgv']) }, zyteApiKey: { configured: false, diff --git a/tests/relay/dtryx.test.ts b/tests/relay/dtryx.test.ts new file mode 100644 index 00000000..de4e5f8f --- /dev/null +++ b/tests/relay/dtryx.test.ts @@ -0,0 +1,358 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createDtryxRelay } from '../../scripts/relay/dtryx.js'; +const token = 'test-token'; +const input = { brandCode: 'art', cinemaCode: '000001', playDate: '20260928' }; +const result = { RetCode: 'success', Recordset: [{ MovieCd: '1' }] }; +const request = (body: unknown = input, path = 'timetable', signal?: AbortSignal) => + new Request(`http://localhost/v1/dtryx/${path}`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify(body), + signal, + }); +const setup = (options = {}) => { + const fetcher = vi.fn(async () => Response.json(result)); + const takeQuota = vi.fn(async () => true); + return { + fetcher, + takeQuota, + handler: createDtryxRelay(token, { fetcher, takeQuota, ...options }), + }; +}; +describe('디트릭스 로컬 릴레이', () => { + it('고정 HTTPS URL과 작업 식별자로 원본 응답을 보존한다', async () => { + const { handler, fetcher } = setup(); + expect(await (await handler(request())).json()).toEqual(result); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.origin).toBe('https://api.dtryx.com'); + expect(url.searchParams.get('PlaySDT')).toBe('2026-09-28'); + expect(url.searchParams.get('WorkGuID')).toBe('37E0BA0F-DA5F-4376-9BA4-B5D27286AB87'); + expect(init.redirect).toBe('manual'); + }); + it.each([ + null, + [], + {}, + { ...input, url: 'http://127.0.0.1' }, + { ...input, headers: {} }, + { ...input, brandCode: 'https://evil' }, + { ...input, brandCode: 'A' }, + { ...input, brandCode: 'a'.repeat(33) }, + { ...input, cinemaCode: 123456 }, + { ...input, playDate: '20260230' }, + { ...input, playDate: '20261301' }, + { ...input, playDate: 1e99 }, + ])('잘못된 입력 거절 %j', async (body) => { + const { handler, takeQuota, fetcher } = setup(); + expect((await handler(request(body))).status).toBe(400); + expect(takeQuota).not.toHaveBeenCalled(); + expect(fetcher).not.toHaveBeenCalled(); + }); + it('인증, 상태 확인, 경로와 메서드를 검사한다', async () => { + const { handler, takeQuota } = setup(); + expect((await handler(new Request('http://localhost/v1/dtryx/health'))).status).toBe(401); + expect( + ( + await handler( + new Request('http://localhost/v1/dtryx/health', { + headers: { Authorization: `Bearer ${token}` }, + }), + ) + ).status, + ).toBe(200); + for (const path of ['constructor', '__proto__', 'toString', 'movies?url=evil']) + expect((await handler(request({}, path))).status).toBe(404); + expect( + ( + await handler( + new Request('http://localhost/v1/dtryx/movies', { + headers: { Authorization: `Bearer ${token}` }, + }), + ) + ).status, + ).toBe(405); + expect(takeQuota).not.toHaveBeenCalled(); + }); + it.each(['play-dates', 'movies'])('날짜 없는 경로 %s', async (path) => { + const { handler } = setup(); + expect((await handler(request({ brandCode: 'a_0-', cinemaCode: '000001' }, path))).status).toBe( + 200, + ); + expect((await handler(request(input, path))).status).toBe(400); + }); + it.each([301, 204, 500])('원본 HTTP %i를 거절한다', async (status) => { + const { handler } = setup({ fetcher: async () => new Response(null, { status }) }); + expect((await handler(request())).status).toBe(502); + }); + it.each([ + null, + [], + {}, + { RetCode: 'failed', Recordset: [] }, + { RetCode: 'success', Recordset: [null] }, + { RetCode: 'success', Recordset: [[]] }, + ])('원본 스키마 거절 %j', async (body) => { + const { handler } = setup({ fetcher: async () => Response.json(body) }); + expect((await handler(request())).status).toBe(502); + }); + it('용량 제한과 JSON 오류를 거절한다', async () => { + const { handler } = setup(); + expect((await handler(request('a'.repeat(16384)))).status).toBe(413); + expect( + ( + await handler( + new Request('http://localhost/v1/dtryx/movies', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: '{', + }), + ) + ).status, + ).toBe(400); + const huge = setup({ fetcher: async () => new Response('a'.repeat(2097153)) }); + expect((await huge.handler(request())).status).toBe(502); + }); + it('할당량 거절 및 저장 실패는 안전하게 처리한다', async () => { + expect((await setup({ takeQuota: async () => false }).handler(request())).status).toBe(429); + expect( + ( + await setup({ + takeQuota: async () => { + throw Error('secret'); + }, + }).handler(request()) + ).status, + ).toBe(503); + }); + it('전체 기한을 원본과 요청 본문에 적용한다', async () => { + vi.useFakeTimers(); + try { + const { handler } = setup({ fetcher: () => new Promise(() => {}) }); + const pending = handler(request()); + await vi.advanceTimersByTimeAsync(15000); + expect((await pending).status).toBe(504); + const stalled = new Request('http://localhost/v1/dtryx/movies', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: new ReadableStream(), + duplex: 'half', + } as RequestInit); + const bodyPending = handler(stalled); + await vi.advanceTimersByTimeAsync(15000); + expect((await bodyPending).status).toBe(504); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + it('대기 취소는 예산을 소비하지 않고 동시4개·전체32개를 제한한다', async () => { + const releases: (() => void)[] = []; + const fetcher = vi.fn( + () => new Promise((resolve) => releases.push(() => resolve(Response.json(result)))), + ); + const { handler, takeQuota } = setup({ fetcher }); + const controllers = Array.from({ length: 32 }, () => new AbortController()); + const pending = controllers.map((c) => handler(request(input, 'timetable', c.signal))); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(4)); + expect((await handler(request())).status).toBe(503); + controllers[4].abort(); + expect((await pending[4]).status).toBe(499); + expect(takeQuota).toHaveBeenCalledTimes(4); + releases[0](); + await vi.waitFor(() => expect(fetcher).toHaveBeenCalledTimes(5)); + controllers.forEach((c) => c.abort()); + await Promise.all(pending); + expect((await setup().handler(request())).status).toBe(200); + }); + it('빈 토큰·본문·이미 취소된 요청과 오류 스트림을 처리한다', async () => { + expect(() => createDtryxRelay(' ', { takeQuota: async () => true })).toThrow(); + const { handler } = setup(); + const empty = new Request('http://localhost/v1/dtryx/movies', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + expect((await handler(empty)).status).toBe(400); + const controller = new AbortController(); + controller.abort(); + expect((await handler(request(input, 'timetable', controller.signal))).status).toBe(499); + const stream = new ReadableStream({ + start(c) { + c.error(Error('stream')); + }, + }); + const broken = new Request('http://localhost/v1/dtryx/movies', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: stream, + duplex: 'half', + } as RequestInit); + expect((await handler(broken)).status).toBe(400); + }); + it('기본 fetch와 같은 길이의 잘못된 인증을 검사한다', async () => { + vi.stubGlobal('fetch', async () => Response.json(result)); + try { + const handler = createDtryxRelay(token, { takeQuota: async () => true }); + expect((await handler(request())).status).toBe(200); + expect( + ( + await handler( + new Request('http://localhost/v1/dtryx/health', { + headers: { Authorization: 'Bearer test-tokem' }, + }), + ) + ).status, + ).toBe(401); + } finally { + vi.unstubAllGlobals(); + } + }); + it('본문 파싱 직후 취소하면 큐나 예산을 소비하지 않는다', async () => { + const controller = new AbortController(); + const { handler, takeQuota } = setup(); + const original = JSON.parse; + const spy = vi.spyOn(JSON, 'parse').mockImplementation((...args) => { + const value = original(...args); + controller.abort(); + return value; + }); + try { + expect((await handler(request(input, 'timetable', controller.signal))).status).toBe(499); + } finally { + spy.mockRestore(); + } + expect(takeQuota).not.toHaveBeenCalled(); + }); + + it('리다이렉트 본문 취소가 멈춰도 오류 응답을 반환한다', async () => { + vi.useFakeTimers(); + try { + const { handler } = setup({ + fetcher: async () => + new Response(new ReadableStream({ cancel: () => new Promise(() => {}) }), { + status: 302, + }), + }); + let response: Response | undefined; + const pending = handler(request()).then((value) => { + response = value; + }); + await vi.advanceTimersByTimeAsync(15000); + expect(response?.status).toBe(502); + await pending; + } finally { + vi.useRealTimers(); + } + }); + it('대기 중 기한 만료는 슬롯과 타이머를 반환하고 예산을 소비하지 않는다', async () => { + vi.useFakeTimers(); + try { + let blocked = true; + const { handler, takeQuota } = setup({ + fetcher: () => (blocked ? new Promise(() => {}) : Promise.resolve(Response.json(result))), + }); + const pending = Array.from({ length: 6 }, () => handler(request())); + await vi.advanceTimersByTimeAsync(14999); + expect(takeQuota).toHaveBeenCalledTimes(4); + await vi.advanceTimersByTimeAsync(1); + expect((await Promise.all(pending)).every((r) => r.status === 504)).toBe(true); + expect(takeQuota).toHaveBeenCalledTimes(4); + blocked = false; + expect((await handler(request())).status).toBe(200); + expect(vi.getTimerCount()).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it('정리 실패와 원본 JSON 오류도 안전하게 처리한다', async () => { + const { handler } = setup({ + fetcher: async () => + new Response(new ReadableStream({ cancel: () => Promise.reject(Error('cancel')) }), { + status: 302, + }), + }); + expect((await handler(request())).status).toBe(502); + expect( + (await setup({ fetcher: async () => new Response('{') }).handler(request())).status, + ).toBe(502); + }); + it('연결 실패 내용을 숨기고 이미 취소된 오류 스트림의 거절을 관찰한다', async () => { + const network = setup({ + fetcher: async () => { + throw Error('private upstream detail'); + }, + }); + const response = await network.handler(request()); + expect(response.status).toBe(502); + expect(await response.text()).not.toContain('private'); + const controller = new AbortController(); + controller.abort(); + const body = new ReadableStream({ + start(c) { + c.error(Error('already failed')); + }, + }); + const req = new Request('http://localhost/v1/dtryx/movies', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body, + signal: controller.signal, + duplex: 'half', + } as RequestInit); + expect((await setup().handler(req)).status).toBe(499); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + it('인증된 상태 응답으로 실행과 대기 요청 수를 관찰한다', async () => { + const { handler } = setup({ fetcher: () => new Promise(() => {}) }); + const controllers = Array.from({ length: 5 }, () => new AbortController()); + const pending = controllers.map((c) => handler(request(input, 'timetable', c.signal))); + const health = () => + handler( + new Request('http://localhost/v1/dtryx/health', { + headers: { Authorization: `Bearer ${token}` }, + }), + ); + await vi.waitFor(async () => + expect(await (await health()).json()).toEqual({ + status: 'ok', + active: 4, + outstanding: 5, + queued: 1, + }), + ); + controllers.forEach((c) => c.abort()); + await Promise.all(pending); + expect(await (await health()).json()).toEqual({ + status: 'ok', + active: 0, + outstanding: 0, + queued: 0, + }); + }); + + it('대기 요청은 FIFO 순서로 원본 호출을 시작한다', async () => { + const started: string[] = []; + const releases: (() => void)[] = []; + const { handler } = setup({ + fetcher: (url: URL) => { + started.push(url.searchParams.get('CinemaCd') as string); + return new Promise((resolve) => + releases.push(() => resolve(Response.json(result))), + ); + }, + }); + const controllers = Array.from({ length: 6 }, () => new AbortController()); + const pending = controllers.map((c, i) => + handler(request({ ...input, cinemaCode: String(i).padStart(6, '0') }, 'timetable', c.signal)), + ); + await vi.waitFor(() => expect(started).toHaveLength(4)); + releases[1](); + await vi.waitFor(() => expect(started).toHaveLength(5)); + releases[0](); + await vi.waitFor(() => + expect(started).toEqual(['000000', '000001', '000002', '000003', '000004', '000005']), + ); + controllers.forEach((c) => c.abort()); + await Promise.all(pending); + }); +}); diff --git a/tests/services/dtryx/relay.test.ts b/tests/services/dtryx/relay.test.ts new file mode 100644 index 00000000..62a51b95 --- /dev/null +++ b/tests/services/dtryx/relay.test.ts @@ -0,0 +1,170 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { + fetchDtryxNowShowing, + fetchDtryxPlayDates, + fetchDtryxTimetable, +} from '../../../src/services/dtryx/client.js'; +const params = { brandCode: 'indieart', cinemaCode: '000067' }; +const options = { + relayUrl: 'https://relay.example', + relayToken: 'test-token', + accessClientId: 'test-id', + accessClientSecret: 'test-secret', +}; +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); +it('세 경로를 고정 POST와 요청별 인증으로 중계한다', async () => { + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve(Response.json({ RetCode: 'success', Recordset: [] })), + ); + vi.stubGlobal('fetch', fetchMock); + await fetchDtryxNowShowing(params, options); + await fetchDtryxPlayDates(params, options); + await fetchDtryxTimetable({ ...params, playDate: '2026-09-28' }, options); + expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([ + 'https://relay.example/v1/dtryx/movies', + 'https://relay.example/v1/dtryx/play-dates', + 'https://relay.example/v1/dtryx/timetable', + ]); + expect(fetchMock.mock.calls[2][1]).toMatchObject({ + method: 'POST', + redirect: 'manual', + body: JSON.stringify({ ...params, playDate: '20260928' }), + headers: { + Authorization: 'Bearer test-token', + 'CF-Access-Client-Id': 'test-id', + 'CF-Access-Client-Secret': 'test-secret', + }, + }); +}); +it.each([429, 502, 503, 504, 302, 401, 500])( + '중계 오류 %s를 안전하게 전달하고 재시도하지 않는다', + async (status) => { + const fetchMock = vi + .fn() + .mockResolvedValue(new Response('test-secret private upstream', { status })); + vi.stubGlobal('fetch', fetchMock); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ + status: [429, 502, 503, 504].includes(status) ? status : 502, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }, +); +it.each([ + 'http://remote.example', + 'https://user:pass@relay.example', + 'https://relay.example?secret=a', + 'https://relay.example/#x', + '', + 'bad', +])('잘못된 URL %s 설정에서 직접 호출하지 않는다', async (relayUrl) => { + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + await expect(fetchDtryxNowShowing(params, { ...options, relayUrl })).rejects.toMatchObject({ + code: 'DTRYX_RELAY_CONFIG_ERROR', + retryable: false, + }); + expect(fetchMock).not.toHaveBeenCalled(); +}); +it.each([ + { relayToken: '' }, + { accessClientId: '' }, + { accessClientSecret: undefined }, + { relayUrl: undefined }, +])('불완전한 중계 설정을 거부한다 %j', async (overrides) => { + vi.stubGlobal('fetch', vi.fn()); + await expect(fetchDtryxNowShowing(params, { ...options, ...overrides })).rejects.toMatchObject({ + code: 'DTRYX_RELAY_CONFIG_ERROR', + }); +}); +it.each([ + 'private invalid json', + '{"RetCode":"fail","Recordset":[]}', + '{"RetCode":"success","Recordset":[null]}', +])('잘못된 본문을 노출하지 않는다 %s', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ + code: 'DTRYX_RELAY_FAILED', + status: 502, + }); +}); +it('네트워크 오류 원문을 노출하지 않는다', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('test-secret'))); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ + message: '디트릭스 릴레이 요청에 실패했습니다.', + status: 502, + }); +}); +it('본문 읽기에도 제한 시간을 적용한다', async () => { + vi.useFakeTimers(); + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url, init) => + Promise.resolve({ + status: 200, + json: () => + new Promise((_resolve, reject) => + init.signal.addEventListener('abort', () => + reject(new DOMException('aborted', 'AbortError')), + ), + ), + }), + ), + ); + const result = expect( + fetchDtryxNowShowing({ ...params, timeout: 10 }, options), + ).rejects.toMatchObject({ status: 504 }); + await vi.advanceTimersByTimeAsync(10); + await result; +}); +it('로컬 중계는 Access 없이 사용할 수 있다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue(Response.json({ RetCode: 'success', Recordset: [] })), + ); + await expect( + fetchDtryxNowShowing(params, { relayUrl: 'http://127.0.0.1:4320/', relayToken: 'test' }), + ).resolves.toEqual([]); +}); +it.each([ + 'null', + '{}', + '{"RetCode":"success"}', + '{"RetCode":"success","Recordset":{}}', + '{"RetCode":"success","Recordset":[[]]}', +])('다양한 잘못된 응답 계약을 거부한다 %s', async (body) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(body))); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 }); +}); +it('실패 응답 본문을 취소하되 취소 완료를 기다리지 않는다', async () => { + const cancel = vi.fn().mockImplementation(() => new Promise(() => {})); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ status: 302, body: { cancel } })); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 }); + expect(cancel).toHaveBeenCalledOnce(); +}); +it('실패 응답 본문 취소 오류를 흡수한다', async () => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + status: 302, + body: { cancel: () => Promise.reject(new Error('private')) }, + }), + ); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 }); +}); +it('본문 없는 실패 응답을 처리한다', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status: 503 }))); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 503 }); +}); +it('실패 응답 이후 연결도 중단한다', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue({ status: 302, body: { cancel: () => new Promise(() => {}) } }); + vi.stubGlobal('fetch', fetchMock); + await expect(fetchDtryxNowShowing(params, options)).rejects.toMatchObject({ status: 502 }); + expect(fetchMock.mock.calls[0][1].signal.aborted).toBe(true); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7b9d8a7a..a7a62992 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['src/**/*.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/browser-environment.ts'], + include: ['src/**/*.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/browser-environment.ts'], // 커버리지 제외 파일 exclude: [