운영버킷 배포
Deploy EG-BIM QA Gateway / deploy (push) Failing after 12s

This commit is contained in:
root committed 2026-11-11 11:03:51 +09:00
1 parent 0d9b1fd703
commit 7c09c89da5
9 files changed
+202 -31

No files matched your search

+5 -1
View File
@@ -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
+25 -15
View File
@@ -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/<SHA>/...`에 올린다.
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/<SHA>/...`에 올린다.
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 경로는 보관한다.
+3 -2
View File
@@ -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"
+3 -1
View File
@@ -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);
+33 -5
View File
@@ -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;
+37
View File
@@ -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; }
}
+49
View File
@@ -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;
});
});
+41 -4
View File
@@ -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');
+6 -3
View File
@@ -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"