diff --git a/.gitea/workflows/deploy-production.yml b/.gitea/workflows/deploy-production.yml index 29f6c34..40c7463 100644 --- a/.gitea/workflows/deploy-production.yml +++ b/.gitea/workflows/deploy-production.yml @@ -17,11 +17,12 @@ jobs: SESSION_SECRET: ${{ secrets.PROD_SESSION_SECRET }} ABC_API_KEY: ${{ secrets.PROD_ABC_API_KEY }} AUTH_CLIENT_ID: ${{ vars.PROD_AUTH_CLIENT_ID }} + AUTH_ISSUER: ${{ vars.PROD_AUTH_ISSUER }} ABC_API_BASE_URL: ${{ vars.PROD_ABC_API_BASE_URL }} ABC_PROJECT_ID: ${{ vars.PROD_ABC_PROJECT_ID }} ABC_CHANNEL_ID: ${{ vars.PROD_ABC_CHANNEL_ID }} ORG_CONTEXT_ENABLED: ${{ vars.PROD_ORG_CONTEXT_ENABLED }} - ORG_CONTEXT_API_URL: ${{ vars.PROD_ORG_CONTEXT_API_URL }} + ORG_CONTEXT_API_URL: ${{ vars.PROD_ORG_CONTEXT_API_URL || 'https://app.brsw.kr/api/v1/integrations/org-context' }} ORG_CONTEXT_SCOPE_MODE: ${{ vars.PROD_ORG_CONTEXT_SCOPE_MODE }} ORG_CONTEXT_EXPOSURE: ${{ vars.PROD_ORG_CONTEXT_EXPOSURE }} ORG_CONTEXT_TENANT_IDS: ${{ vars.PROD_ORG_CONTEXT_TENANT_IDS }} @@ -57,6 +58,9 @@ jobs: - name: Verify production organization API run: npm run org:verify + - name: Verify production ABC project and channel access + run: npm run abc:verify + - name: Build production Worker without deploying run: npm run deploy:production -- --dry-run diff --git a/docs/qa-production-pipeline.md b/docs/qa-production-pipeline.md index 23dc850..8b09651 100644 --- a/docs/qa-production-pipeline.md +++ b/docs/qa-production-pipeline.md @@ -9,6 +9,11 @@ | 실행 | Gitea Actions에서 `main`을 선택해 수동 실행 | | Worker | `baron-qa-gateway-prod` | | Custom Domain | `https://qa.baroncs.co.kr` | +| EGBIM 작성페이지 | `https://qa.baroncs.co.kr/egbim/` | +| 운영 ABC API | `https://qa.brsw.kr` | +| 운영 EGBIM 프로젝트 ID | `01a1c543-ab4f-744f-8eff-ab85f2644271` | +| 운영 Q&A 채널 ID | `01a1c544-35ac-7674-9bf0-2469bfbe3e52` | +| 운영 SSO issuer | `https://app.brsw.kr/oidc` | | Cloudflare account ID | `81fa2d48964d31dd0da9558f9ce601d1` | | 정적 파일 R2 bucket | `baron-qa-write` | | R2 binding 이름 | `QA_BUCKET` | @@ -26,7 +31,7 @@ |---|---| | `PROD_CLOUDFLARE_API_TOKEN` | 기존 운영 Worker를 배포하고 R2 파일을 업로드할 수 있는 Cloudflare token | | `PROD_SESSION_SECRET` | 최소 32자 운영 세션 서명키. 예: `openssl rand -hex 32`로 생성한 값 | -| `PROD_ABC_API_KEY` | **등록 보류:** 관리페이지 서버 확정 후 운영 ABC project/channel API key 등록 | +| `PROD_ABC_API_KEY` | 운영 EGBIM 프로젝트에서 발급한 API key. 작성페이지 저장소에 별도 등록 | | `PROD_ORG_CONTEXT_KEY_ID` | 운영 조직 컨텍스트 연동 Key ID | | `PROD_ORG_CONTEXT_KEY_SECRET` | 운영 조직 컨텍스트 연동 Key Secret | @@ -50,22 +55,25 @@ R2 권한은 계정 범위의 **Workers R2 Storage Write/Edit**를 선택한다. | 이름 | 필수 | 값 | |---|---|---| -| `PROD_AUTH_CLIENT_ID` | 필수 | 운영 callback URI가 허용된 SSO client ID | -| `PROD_ABC_API_BASE_URL` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC HTTPS origin 등록. `/api` 경로를 붙이지 않는다. | -| `PROD_ABC_PROJECT_ID` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC project UUID 등록 | -| `PROD_ABC_CHANNEL_ID` | 필수 / 등록 보류 | 관리페이지 서버 확정 후 운영 ABC channel UUID 등록 | +| `PROD_AUTH_CLIENT_ID` | 필수 | 운영 SSO의 Public PKCE client ID. callback은 `https://qa.baroncs.co.kr/auth/callback` | +| `PROD_AUTH_ISSUER` | 선택 | 기본 `https://app.brsw.kr/oidc`. authorization·token·userinfo 주소를 이 issuer에서 생성 | +| `PROD_ABC_API_BASE_URL` | 선택 | 기본 `https://qa.brsw.kr`. 다른 origin은 배포 차단. `/api`와 `/egbim` 경로를 붙이지 않는다. | +| `PROD_ABC_PROJECT_ID` | 선택 | 기본 `01a1c543-ab4f-744f-8eff-ab85f2644271`. 다른 값은 배포 차단 | +| `PROD_ABC_CHANNEL_ID` | 선택 | 기본 `01a1c544-35ac-7674-9bf0-2469bfbe3e52`. EGBIM의 Q&A 채널이며 다른 값은 배포 차단 | | `PROD_ORG_CONTEXT_ENABLED` | 필수 | `true` | | `PROD_ORG_CONTEXT_TENANT_IDS` | 필수 | 기준 tenant UUID JSON 배열. 예: `["tenant-UUID"]` | -| `PROD_ORG_CONTEXT_API_URL` | 선택 | 기본 `https://sadmin.hmac.kr/api/v1/integrations/org-context` | +| `PROD_ORG_CONTEXT_API_URL` | 선택 | 기본 `https://app.brsw.kr/api/v1/integrations/org-context` | | `PROD_ORG_CONTEXT_SCOPE_MODE` | 선택 | 기본 `SCOPED` | | `PROD_ORG_CONTEXT_EXPOSURE` | 선택 | 기본 `identifiers` | | `PROD_ORG_CONTEXT_CACHE_TTL_SECONDS` | 선택 | 기본 `300`, 허용 범위 1~3600 | SSO에는 `https://qa.baroncs.co.kr/auth/callback`을 허용한다. Q&A 로그아웃은 Worker가 세션 쿠키를 삭제한 뒤 `AUTH_POST_LOGOUT_REDIRECT_URI`에 설정된 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다. 이 이동은 중앙 SSO 로그아웃 API를 사용하지 않는다. 등록된 SSO client나 ABC project/channel이 운영 대상인지 확정한 후 배포한다. -지금 준비할 Variables는 `PROD_AUTH_CLIENT_ID`와 `PROD_ORG_CONTEXT_*` 6개다. `PROD_AUTH_CLIENT_ID`의 참고값은 기존 스테이징 client `e16898a7-acd2-420a-a224-cdcd89ff66cd`이며, 이 client에 운영 callback이 허용된 경우에만 운영에서도 재사용한다. 별도 운영 client를 만들었다면 그 ID를 등록한다. 기준 tenant UUID와 조직 연동 키는 실제 발급받은 값을 사용한다. 기존 `STAGING_*` 등록은 `PROD_*` 등록으로 간주하지 않는다. +`PROD_AUTH_CLIENT_ID`는 운영 issuer에 등록된 Public PKCE client여야 한다. 관리페이지의 비공개 Server-side Web client를 그대로 사용하지 않는다. 운영 작성페이지 callback `https://qa.baroncs.co.kr/auth/callback`과 필요한 scope를 해당 client에 허용한다. 기준 tenant UUID와 조직 연동 키는 운영 값을 사용한다. 기존 `STAGING_*` 등록은 `PROD_*` 등록으로 간주하지 않는다. -회사·부서 조회용 sadmin 조직 컨텍스트 API 설정은 ABC 관리페이지 연동 설정과 별개다. 조직 API 주소·기준 tenant·키의 운영 사용이 확정된 경우 미리 등록할 수 있다. Secret 값이나 테스트 fixture UUID를 실제 설정값으로 문서에 기록하지 않는다. +작성페이지의 `PROD_ABC_API_KEY`는 이 저장소(`disong/baron_qa_write`)의 Secret이다. 관리페이지 저장소의 `PROD_SECRETARY_ABC_API_KEY`와 등록 위치가 다르다. 브라우저는 작성페이지 Worker의 `/api/feedbacks`를 호출하고, Worker만 이 키로 운영 ABC API를 호출한다. `egbim/config.js`에 API 키를 넣지 않는다. + +회사·부서 조회용 조직 컨텍스트 API 설정은 ABC 관리페이지 연동 설정과 별개다. 조직 API 주소·기준 tenant·키의 운영 사용이 확정된 경우 미리 등록할 수 있다. Secret 값이나 테스트 fixture UUID를 실제 설정값으로 문서에 기록하지 않는다. Worker runtime에는 `PROD_`를 제거한 이름으로 주입한다. `DEPLOY_COMMIT_SHA`는 workflow가 선택된 커밋에서 자동으로 전달하므로 Gitea에 따로 등록하지 않는다. @@ -76,19 +84,21 @@ Worker runtime에는 `PROD_`를 제거한 이름으로 주입한다. `DEPLOY_COM 3. `npm ci`로 고정된 의존성을 설치한다. 4. `npm run check`와 `npm test`를 실행한다. 5. `npm run org:verify`로 실제 운영 조직 API의 인증·범위·구성원 응답을 검증한다. -6. `npm run deploy:production -- --dry-run`으로 Worker 번들을 만든다. 이 단계는 Cloudflare 리소스를 변경하지 않는다. -7. 해당 커밋의 정적 파일을 `baron-qa-write/releases//...`에 올린다. -8. `npm run deploy:production`으로 코드·runtime 변수·Secret을 같은 Worker 배포에 반영한다. -9. `npm run production:verify`로 익명 세션, CSS 응답·커밋 일치, 비로그인 글쓰기 redirect를 확인한다. 전파 지연에 대비해 최대 5회 확인한다. +6. `npm run abc:verify`로 운영 프로젝트·채널·API key와 문의 필드를 읽기 전용으로 확인한다. 잘못된 키·UUID·필드 구성은 정적 파일 업로드 전에 차단한다. +7. `npm run deploy:production -- --dry-run`으로 Worker 번들을 만든다. 이 단계는 Cloudflare 리소스를 변경하지 않는다. +8. 해당 커밋의 정적 파일을 `baron-qa-write/releases//...`에 올린다. +9. `npm run deploy:production`으로 코드·runtime 변수·Secret을 같은 Worker 배포에 반영한다. +10. `npm run production:verify`로 익명 세션, CSS 응답·커밋 일치, 비로그인 글쓰기 redirect를 확인한다. 전파 지연에 대비해 최대 5회 확인한다. Secret 파일은 임시 디렉터리에 접근 권한 `0600`으로 만들고 배포 명령 종료 후 삭제한다. Secret 값은 CLI 인자나 로그로 출력하지 않는다. ## 첫 운영 배포 실행 -현재 `PROD_ABC_API_KEY`, `PROD_ABC_API_BASE_URL`, `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID` 등록은 관리페이지 서버 확정까지 보류한다. 이 값들이 없으면 필수 설정 검증에서 배포를 중단한다. SSO·조직 컨텍스트·Cloudflare·세션 서명키 설정은 먼저 준비할 수 있다. +운영 API 주소와 사용자가 확정한 EGBIM 프로젝트·Q&A 채널 UUID를 운영 기본값으로 반영했다. Gitea의 `PROD_ABC_API_BASE_URL`·`PROD_ABC_PROJECT_ID`·`PROD_ABC_CHANNEL_ID`가 비어 있으면 이 값을 사용하며, 다른 값이면 업로드 전에 중단한다. 운영 프로젝트 API key는 별도 Secret이 필요하다. 실제 도메인이 `Hello World!`만 반환하면 Q&A 작성페이지 코드가 해당 도메인에서 서비스되지 않는 상태다. Worker 코드 배포와 Cloudflare custom domain의 대상 Worker를 함께 확인한다. 관리 서버의 CORS 설정만으로 작성페이지가 배포되지는 않는다. - [x] 관리페이지 연동 `PROD_ABC_*` 4개를 제외한 운영 Secrets와 Variables 등록을 완료했다. 사용자 보고 기준이며 실제 값·권한 검증은 아직 수행하지 않았다. -- [ ] 관리페이지 서버 확정 후 `PROD_ABC_API_KEY`, `PROD_ABC_API_BASE_URL`, `PROD_ABC_PROJECT_ID`, `PROD_ABC_CHANNEL_ID`를 등록한다. +- [x] 작성페이지 저장소의 `PROD_ABC_API_KEY` 등록을 완료했다. 사용자 보고 기준이며 실제 키 검증은 workflow에서 수행한다. +- [x] 운영 EGBIM 프로젝트·Q&A 채널 UUID를 사용자에게 확인하고 운영 기본 설정에 반영했다. - [x] 운영 SSO callback과 관련 변수 등록을 완료했다. 사용자 완료 보고 기준이다. - [ ] ABC project/channel과 조직 API의 실제 접근 권한을 확인한다. - [ ] 이 변경을 `main`에 반영한다. @@ -98,7 +108,7 @@ Secret 파일은 임시 디렉터리에 접근 권한 `0600`으로 만들고 배 ## 실패와 롤백 -- 설정·조직 API·빌드 검증 실패는 운영 정적 파일 업로드와 Worker 배포 전에 중단된다. +- 설정·조직 API·ABC 연결·빌드 검증 실패는 운영 정적 파일 업로드와 Worker 배포 전에 중단된다. - 정적 파일 업로드가 실패해도 기존 Worker는 이전 커밋 경로를 계속 읽는다. 다시 실행하면 같은 커밋 경로로 업로드한다. - Worker 배포 후 HTTP 확인이 실패하면 workflow는 실패로 표시되지만 이미 반영한 배포를 자동으로 되돌리지는 않는다. 마지막 정상 Worker 버전으로 명시적으로 롤백한다. - Cloudflare의 `baron-qa-gateway-prod` **Deployments**에서 마지막 정상 버전으로 rollback하면 해당 버전의 `QA_ASSET_PREFIX`와 정적 파일을 다시 사용한다. 이전 release 경로는 보관한다. diff --git a/package.json b/package.json index 9a87df2..d9c4801 100644 --- a/package.json +++ b/package.json @@ -6,11 +6,12 @@ "deploy": "wrangler deploy", "deploy:production": "node scripts/deploy-production.mjs", "r2:upload": "bash scripts/upload-r2.sh", - "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check assets/inquiry/entry.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh", + "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js && node --check assets/inquiry/entry.js && node --check scripts/verify-org-context.mjs && node --check scripts/production-settings.mjs && node --check scripts/deploy-production.mjs && node --check scripts/verify-production.mjs && bash -n scripts/upload-r2.sh && node --check scripts/verify-abc.mjs", "test": "node --test --test-isolation=none tests/*.test.js", "test:ui": "node tests/browser/inquiry-ui.mjs", "org:verify": "node scripts/verify-org-context.mjs", - "production:verify": "node scripts/verify-production.mjs" + "production:verify": "node scripts/verify-production.mjs", + "abc:verify": "node scripts/verify-abc.mjs" }, "devDependencies": { "wrangler": "4.134.0" diff --git a/scripts/deploy-production.mjs b/scripts/deploy-production.mjs index d5d15e2..04776da 100644 --- a/scripts/deploy-production.mjs +++ b/scripts/deploy-production.mjs @@ -17,7 +17,9 @@ try { const options = process.argv.slice(2); if (options.some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown production deployment option'); const settings = productionSettings(process.env); - console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin, assets: settings.vars.QA_ASSET_PREFIX })); + console.log(JSON.stringify({ worker: settings.worker, bucket: settings.bucket, origin: settings.origin, + api: settings.vars.ABC_API_BASE_URL, authorize: settings.vars.AUTH_AUTHORIZE_URL, + assets: settings.vars.QA_ASSET_PREFIX })); if (!options.includes('--validate-only')) { const args = ['deploy', '--config', settings.config, '--name', settings.worker]; for (const [name, value] of Object.entries(settings.vars)) args.push('--var', name + ':' + value); diff --git a/scripts/production-settings.mjs b/scripts/production-settings.mjs index 5f1a2d9..78c6122 100644 --- a/scripts/production-settings.mjs +++ b/scripts/production-settings.mjs @@ -5,7 +5,12 @@ export const productionTarget = Object.freeze({ worker: 'baron-qa-gateway-prod', config: 'wrangler.production.toml', bucket: 'baron-qa-write', - origin: 'https://qa.baroncs.co.kr' + origin: 'https://qa.baroncs.co.kr', + apiOrigin: 'https://qa.brsw.kr', + projectId: '01a1c543-ab4f-744f-8eff-ab85f2644271', + channelId: '01a1c544-35ac-7674-9bf0-2469bfbe3e52', + authIssuer: 'https://app.brsw.kr/oidc', + orgContextApiUrl: 'https://app.brsw.kr/api/v1/integrations/org-context' }); function required(env, name) { @@ -23,6 +28,15 @@ function httpsBase(value, name) { return url.origin; } +function httpsIssuer(value) { + let url; + try { url = new URL(value); } catch { throw new Error('AUTH_ISSUER must be an HTTPS issuer URL'); } + if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) { + throw new Error('AUTH_ISSUER must be an HTTPS issuer URL'); + } + return url.origin + url.pathname.replace(/\/+$/, ''); +} + export function productionSettings(env) { // These names are mapped exclusively from PROD_* settings in the production workflow. required(env, 'CLOUDFLARE_API_TOKEN'); @@ -34,20 +48,34 @@ export function productionSettings(env) { }; if (secrets.SESSION_SECRET.length < 32) throw new Error('SESSION_SECRET must contain at least 32 characters'); if (required(env, 'ORG_CONTEXT_ENABLED') !== 'true') throw new Error('ORG_CONTEXT_ENABLED must be true for production'); - const organization = deploymentOrgSettings(env); + const organization = deploymentOrgSettings({ + ...env, ORG_CONTEXT_API_URL: env.ORG_CONTEXT_API_URL || productionTarget.orgContextApiUrl + }); orgContextConfig({ ...organization.vars, ...secrets }); + const issuer = httpsIssuer(env.AUTH_ISSUER || productionTarget.authIssuer); const vars = { ...organization.vars, AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'), - ABC_API_BASE_URL: httpsBase(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL'), - ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'), - ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID') + AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth', + AUTH_TOKEN_URL: issuer + '/oauth2/token', + AUTH_USERINFO_URL: issuer + '/userinfo', + ABC_API_BASE_URL: httpsBase(env.ABC_API_BASE_URL || productionTarget.apiOrigin, 'ABC_API_BASE_URL'), + ABC_PROJECT_ID: String(env.ABC_PROJECT_ID || productionTarget.projectId).trim(), + ABC_CHANNEL_ID: String(env.ABC_CHANNEL_ID || productionTarget.channelId).trim() }; const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; for (const name of ['ABC_PROJECT_ID', 'ABC_CHANNEL_ID']) { if (!uuid.test(vars[name])) throw new Error(name + ' must be a UUID'); } + // Fail before uploads if a PROD_* variable points at staging or another channel. + for (const [name, expected] of Object.entries({ + ABC_API_BASE_URL: productionTarget.apiOrigin, + ABC_PROJECT_ID: productionTarget.projectId, + ABC_CHANNEL_ID: productionTarget.channelId + })) { + if (vars[name] !== expected) throw new Error(name + ' must match the production EGBIM Q&A target: ' + expected); + } const revision = required(env, 'DEPLOY_COMMIT_SHA'); if (!/^[0-9a-f]{40}$/.test(revision)) throw new Error('DEPLOY_COMMIT_SHA must be a full lowercase Git commit SHA'); vars.QA_ASSET_PREFIX = 'releases/' + revision; diff --git a/scripts/verify-abc.mjs b/scripts/verify-abc.mjs new file mode 100644 index 0000000..06a3a92 --- /dev/null +++ b/scripts/verify-abc.mjs @@ -0,0 +1,37 @@ +import { pathToFileURL } from 'node:url'; +import { productionSettings } from './production-settings.mjs'; + +export async function verifyAbc(env = process.env, fetchImpl = fetch) { + const { vars, secrets } = productionSettings(env); + const endpoint = vars.ABC_API_BASE_URL + '/api/projects/' + encodeURIComponent(vars.ABC_PROJECT_ID) + + '/channels/' + encodeURIComponent(vars.ABC_CHANNEL_ID) + '/fields'; + let response; + try { + response = await fetchImpl(endpoint, { + method: 'GET', redirect: 'error', signal: AbortSignal.timeout(15000), + headers: { accept: 'application/json', 'x-api-key': secrets.ABC_API_KEY } + }); + } catch { + throw new Error('Production ABC API request failed; check PROD_ABC_API_BASE_URL, HTTPS and connectivity'); + } + if (!response.ok) { + throw new Error('Production ABC API returned HTTP ' + response.status + + '; check PROD_ABC_API_KEY, PROD_ABC_PROJECT_ID and PROD_ABC_CHANNEL_ID'); + } + let body; + try { body = await response.json(); } catch { throw new Error('Production ABC fields response is not JSON'); } + const fields = Array.isArray(body) ? body : body?.items || body?.data?.items || body?.data; + if (!Array.isArray(fields)) throw new Error('Production ABC fields response is not a field list'); + const keys = new Set(fields.map((field) => field.key)); + const missing = ['title', 'contents', 'Category'].filter((key) => !keys.has(key)); + if (missing.length) throw new Error('Production Q&A channel is missing required fields: ' + missing.join(', ')); + const categories = new Set((fields.find((field) => field.key === 'Category').options || []).map((option) => option.key)); + const missingCategories = ['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].filter((key) => !categories.has(key)); + if (missingCategories.length) throw new Error('Production Q&A channel is missing category options: ' + missingCategories.join(', ')); + console.log('Production ABC API verified: ' + vars.ABC_API_BASE_URL + ', project ' + vars.ABC_PROJECT_ID + + ', channel ' + vars.ABC_CHANNEL_ID + '. No feedback data was changed.'); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + try { await verifyAbc(); } catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/tests/abc-connection.test.js b/tests/abc-connection.test.js new file mode 100644 index 0000000..c9dfd08 --- /dev/null +++ b/tests/abc-connection.test.js @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { verifyAbc } from '../scripts/verify-abc.mjs'; + +const env = { + CLOUDFLARE_API_TOKEN: 'fixture-cloudflare-token', SESSION_SECRET: 'fixture-session-'.repeat(3), + ABC_API_KEY: 'fixture-project-key', AUTH_CLIENT_ID: 'fixture-public-pkce-client', + ABC_PROJECT_ID: '01a1c543-ab4f-744f-8eff-ab85f2644271', ABC_CHANNEL_ID: '01a1c544-35ac-7674-9bf0-2469bfbe3e52', + ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]', + ORG_CONTEXT_KEY_ID: 'fixture-key-id', ORG_CONTEXT_KEY_SECRET: 'fixture-key-secret', + DEPLOY_COMMIT_SHA: 'a'.repeat(40) +}; +const fields = [{ key: 'title' }, { key: 'contents' }, { + key: 'Category', options: ['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].map((key) => ({ key })) +}]; + +test('ABC connection verification reads the configured production channel without writing data', async () => { + await verifyAbc(env, async (url, options) => { + assert.equal(url, 'https://qa.brsw.kr/api/projects/' + env.ABC_PROJECT_ID + '/channels/' + env.ABC_CHANNEL_ID + '/fields'); + assert.equal(options.method, 'GET'); + assert.equal(options.body, undefined); + assert.equal(options.redirect, 'error'); + assert.equal(options.headers['x-api-key'], env.ABC_API_KEY); + return Response.json(fields); + }); +}); + +test('ABC connection verification blocks wrong production targets before sending credentials', async () => { + let requests = 0; + for (const override of [ + { ABC_API_BASE_URL: 'https://feedback.hmac.kr' }, + { ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002' } + ]) { + await assert.rejects(verifyAbc({ ...env, ...override }, async () => { requests++; }), /must match the production EGBIM Q&A target/); + } + assert.equal(requests, 0); +}); + +test('ABC connection verification rejects wrong keys and channel schemas without printing credentials', async () => { + for (const status of [401, 403, 404]) { + await assert.rejects(verifyAbc(env, async () => new Response('', { status })), new RegExp('HTTP ' + status)); + } + await assert.rejects(verifyAbc(env, async () => Response.json([{ key: 'title' }])), /contents, Category/); + await assert.rejects(verifyAbc(env, async () => Response.json(fields.map((field) => ({ ...field, options: [] })))), /category options/); + await assert.rejects(verifyAbc(env, async () => { throw new Error(env.ABC_API_KEY); }), (error) => { + assert.ok(!error.message.includes(env.ABC_API_KEY)); + return true; + }); +}); diff --git a/tests/production-deployment.test.js b/tests/production-deployment.test.js index 2043422..aa4c203 100644 --- a/tests/production-deployment.test.js +++ b/tests/production-deployment.test.js @@ -12,8 +12,8 @@ import worker from '../src/index.js'; const SHA = 'a'.repeat(40); const fixture = () => ({ CLOUDFLARE_API_TOKEN: 'fixture-cloudflare-token', SESSION_SECRET: 'fixture-session-secret-'.repeat(3), - ABC_API_KEY: 'fixture-abc-key', AUTH_CLIENT_ID: 'production-client', ABC_API_BASE_URL: 'https://feedback.example.test/', - ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001', ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002', + ABC_API_KEY: 'fixture-abc-key', AUTH_CLIENT_ID: 'production-client', ABC_API_BASE_URL: 'https://qa.brsw.kr/', + ABC_PROJECT_ID: '01a1c543-ab4f-744f-8eff-ab85f2644271', ABC_CHANNEL_ID: '01a1c544-35ac-7674-9bf0-2469bfbe3e52', ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]', ORG_CONTEXT_KEY_ID: 'fixture-org-key', ORG_CONTEXT_KEY_SECRET: 'fixture-org-secret', DEPLOY_COMMIT_SHA: SHA }); @@ -24,8 +24,9 @@ test('production preserves dashboard-managed domains without requesting zone rou assert.match(config, /^workers_dev\s*=\s*false\s*$/m); }); -test('production requires its own credentials, target IDs, organization lookup and immutable release', () => { +test('production requires its own credentials, organization lookup and immutable release', () => { for (const name of Object.keys(fixture())) { + if (['ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID'].includes(name)) continue; const env = fixture(); delete env[name]; assert.throws(() => productionSettings(env), new RegExp(name === 'ORG_CONTEXT_TENANT_IDS' ? 'org_context_invalid_tenants' : name)); @@ -38,12 +39,48 @@ test('production requires its own credentials, target IDs, organization lookup a const settings = productionSettings(fixture()); assert.equal(settings.worker, 'baron-qa-gateway-prod'); assert.equal(settings.bucket, 'baron-qa-write'); - assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.example.test'); + assert.equal(settings.vars.ABC_API_BASE_URL, 'https://qa.brsw.kr'); assert.equal(settings.vars.QA_ASSET_PREFIX, 'releases/' + SHA); for (const name of Object.keys(settings.secrets)) assert.equal(settings.vars[name], undefined); assert.equal(settings.secrets.CLOUDFLARE_API_TOKEN, undefined); }); +test('production defaults connect the writing Worker to the production API and SSO', () => { + const env = fixture(); + delete env.ABC_API_BASE_URL; + delete env.ABC_PROJECT_ID; + delete env.ABC_CHANNEL_ID; + const settings = productionSettings(env); + assert.equal(settings.vars.ABC_API_BASE_URL, 'https://qa.brsw.kr'); + assert.equal(settings.vars.ABC_PROJECT_ID, '01a1c543-ab4f-744f-8eff-ab85f2644271'); + assert.equal(settings.vars.ABC_CHANNEL_ID, '01a1c544-35ac-7674-9bf0-2469bfbe3e52'); + assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://app.brsw.kr/oidc/oauth2/auth'); + assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://app.brsw.kr/oidc/oauth2/token'); + assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://app.brsw.kr/oidc/userinfo'); + assert.equal(settings.vars.ORG_CONTEXT_API_URL, 'https://app.brsw.kr/api/v1/integrations/org-context'); + const custom = productionSettings({ ...env, AUTH_ISSUER: 'https://identity.example.test/oidc/' }); + assert.equal(custom.vars.AUTH_AUTHORIZE_URL, 'https://identity.example.test/oidc/oauth2/auth'); + assert.throws(() => productionSettings({ ...env, AUTH_ISSUER: 'http://identity.example.test' }), /AUTH_ISSUER/); +}); + +test('production rejects staging API origins and other project or channel IDs', () => { + for (const override of [ + { ABC_API_BASE_URL: 'https://feedback.hmac.kr' }, + { ABC_API_BASE_URL: 'https://qa-test.baroncs.co.kr' }, + { ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001' }, + { ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002' } + ]) { + assert.throws(() => productionSettings({ ...fixture(), ...override }), /must match the production EGBIM Q&A target/); + withFakeWrangler(({ env, capture }) => { + const result = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/deploy-production.mjs', import.meta.url))], { + env: { ...env, ...override }, encoding: 'utf8' + }); + assert.equal(result.status, 1, result.stderr); + assert.equal(existsSync(capture), false); + }); + } +}); + function withFakeWrangler(body) { const dir = mkdtempSync(join(tmpdir(), 'qa-deploy-test-')); const capture = join(dir, 'commands.jsonl'); diff --git a/wrangler.production.toml b/wrangler.production.toml index 120e174..8cf77bb 100644 --- a/wrangler.production.toml +++ b/wrangler.production.toml @@ -12,9 +12,12 @@ preview_urls = false AUTH_REDIRECT_URI = "https://qa.baroncs.co.kr/auth/callback" AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html" AUTH_SCOPE = "openid tenants profile email" -AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth" -AUTH_TOKEN_URL = "https://sso.hmac.kr/oidc/oauth2/token" -AUTH_USERINFO_URL = "https://sso.hmac.kr/oidc/userinfo" +AUTH_AUTHORIZE_URL = "https://app.brsw.kr/oidc/oauth2/auth" +AUTH_TOKEN_URL = "https://app.brsw.kr/oidc/oauth2/token" +AUTH_USERINFO_URL = "https://app.brsw.kr/oidc/userinfo" +ABC_API_BASE_URL = "https://qa.brsw.kr" +ABC_PROJECT_ID = "01a1c543-ab4f-744f-8eff-ab85f2644271" +ABC_CHANNEL_ID = "01a1c544-35ac-7674-9bf0-2469bfbe3e52" SESSION_COOKIE_NAME = "baron_qa_prod_session_v1" OAUTH_COOKIE_NAME = "baron_qa_prod_oauth_v1" SESSION_TTL_SECONDS = "3600"