Compare commits
8
Commits
308c06516c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2e70c13ac | ||
|
|
e2a6168744 | ||
|
|
bed970a175 | ||
|
|
7c09c89da5 | ||
|
|
0d9b1fd703 | ||
|
|
319a8afaab | ||
|
|
beaa2fc423 | ||
|
|
2b37bed2b8 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
+20
-14
@@ -10,17 +10,22 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
SESSION_SECRET: ${{ secrets.SESSION_SECRET }}
|
||||
ABC_API_KEY: ${{ secrets.ABC_API_KEY }}
|
||||
ORG_CONTEXT_ENABLED: ${{ vars.STAGING_ORG_CONTEXT_ENABLED }}
|
||||
ORG_CONTEXT_API_URL: ${{ vars.STAGING_ORG_CONTEXT_API_URL }}
|
||||
ORG_CONTEXT_SCOPE_MODE: ${{ vars.STAGING_ORG_CONTEXT_SCOPE_MODE }}
|
||||
ORG_CONTEXT_EXPOSURE: ${{ vars.STAGING_ORG_CONTEXT_EXPOSURE }}
|
||||
ORG_CONTEXT_TENANT_IDS: ${{ vars.STAGING_ORG_CONTEXT_TENANT_IDS }}
|
||||
ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS }}
|
||||
ORG_CONTEXT_KEY_ID: ${{ secrets.STAGING_ORG_CONTEXT_KEY_ID }}
|
||||
ORG_CONTEXT_KEY_SECRET: ${{ secrets.STAGING_ORG_CONTEXT_KEY_SECRET }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.STG_CLOUDFLARE_API_TOKEN }}
|
||||
SESSION_SECRET: ${{ secrets.STG_SESSION_SECRET }}
|
||||
ABC_API_KEY: ${{ secrets.STG_ABC_API_KEY }}
|
||||
AUTH_CLIENT_ID: ${{ vars.STG_AUTH_CLIENT_ID }}
|
||||
AUTH_ISSUER: ${{ vars.STG_AUTH_ISSUER }}
|
||||
ABC_API_BASE_URL: ${{ vars.STG_ABC_API_BASE_URL }}
|
||||
ABC_PROJECT_ID: ${{ vars.STG_ABC_PROJECT_ID }}
|
||||
ABC_CHANNEL_ID: ${{ vars.STG_ABC_CHANNEL_ID }}
|
||||
ORG_CONTEXT_ENABLED: ${{ vars.STG_ORG_CONTEXT_ENABLED }}
|
||||
ORG_CONTEXT_API_URL: ${{ vars.STG_ORG_CONTEXT_API_URL }}
|
||||
ORG_CONTEXT_SCOPE_MODE: ${{ vars.STG_ORG_CONTEXT_SCOPE_MODE }}
|
||||
ORG_CONTEXT_EXPOSURE: ${{ vars.STG_ORG_CONTEXT_EXPOSURE }}
|
||||
ORG_CONTEXT_TENANT_IDS: ${{ vars.STG_ORG_CONTEXT_TENANT_IDS }}
|
||||
ORG_CONTEXT_CACHE_TTL_SECONDS: ${{ vars.STG_ORG_CONTEXT_CACHE_TTL_SECONDS }}
|
||||
ORG_CONTEXT_KEY_ID: ${{ secrets.STG_ORG_CONTEXT_KEY_ID }}
|
||||
ORG_CONTEXT_KEY_SECRET: ${{ secrets.STG_ORG_CONTEXT_KEY_SECRET }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -35,9 +40,10 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test -n "$CLOUDFLARE_API_TOKEN" || { echo "CLOUDFLARE_API_TOKEN is missing"; exit 1; }
|
||||
test -n "$SESSION_SECRET" || { echo "SESSION_SECRET is missing"; exit 1; }
|
||||
test -n "$ABC_API_KEY" || { echo "ABC_API_KEY is missing"; exit 1; }
|
||||
test -n "$CLOUDFLARE_API_TOKEN" || { echo "STG_CLOUDFLARE_API_TOKEN is missing"; exit 1; }
|
||||
test -n "$SESSION_SECRET" || { echo "STG_SESSION_SECRET is missing"; exit 1; }
|
||||
test -n "$ABC_API_KEY" || { echo "STG_ABC_API_KEY is missing"; exit 1; }
|
||||
node scripts/deploy-worker.mjs --validate-only
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --no-fund --no-audit
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# EG-BIM Q&A static pages
|
||||
|
||||
`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다.
|
||||
`egbim/index.html`의 목록 위에 작성·상세 모달을 여는 EG-BIM Q&A UI입니다. `SW문의_소스패키지`의 히어로·제품 탭·목록·모달 디자인을 적용하고, 기존 첨부·댓글·비밀글·작성자 권한·검색을 유지합니다. 회사 홈페이지의 헤더·메뉴·푸터는 포함하지 않습니다. 기존 `write.html`, `detail.html?id=...` 주소는 같은 목록의 모달로 연결됩니다. 상단 오른쪽 사람 아이콘의 계정 메뉴에서 로그인 사용자 이름을 확인하고 로그아웃할 수 있습니다.
|
||||
|
||||
EG-BIM을 우선 제공하며 TOVA·GAIA는 준비중 탭입니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다.
|
||||
EG-BIM 탭만 가운데 표시하며 TOVA·GAIA는 오픈 전까지 숨깁니다. 원격지원은 `https://939.co.kr/saman/`으로 열립니다. UI 적용 범위·검증·남은 배포 확인은 [작업 목록](docs/qa-inquiry-ui-redesign-tasks.md)에 정리했습니다.
|
||||
|
||||
로그인부터 글 저장까지의 화면·처리 전이맵은 [`docs/qa-login-to-save-transition.md`](docs/qa-login-to-save-transition.md)에 정리했습니다.
|
||||
|
||||
@@ -78,17 +78,41 @@ npx wrangler secret bulk qa-secrets.json --name baron-qa-gateway-test
|
||||
|
||||
Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증한 사용자 claim을 서명된 HttpOnly 세션 쿠키에 저장합니다. 브라우저의 `GET /auth/session`은 정규화된 작성자 정보만 반환합니다.
|
||||
|
||||
로그아웃 메뉴는 `/auth/logout`으로 이동해 해당 환경의 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 돌아갑니다. 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`에 같은 목적지를 설정합니다. 목적지는 서버 배포 설정에서만 읽으며 요청 쿼리로 변경할 수 없습니다. 중앙 SSO의 로그인 종료 요청은 별도로 보내지 않습니다.
|
||||
|
||||
## Gitea Actions 등록값
|
||||
|
||||
운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 아래 기존 등록값은 스테이징 배포용이다.
|
||||
운영 `qa.baroncs.co.kr` 배포는 [production 파이프라인 안내](docs/qa-production-pipeline.md)를 따른다. 운영 workflow는 `Deploy EG-BIM QA Production`이며 `main`에서 수동으로 실행한다. 스테이징 Actions 설정은 `STG_*`, 운영은 `PROD_*` 이름을 사용한다.
|
||||
|
||||
저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다.
|
||||
저장소 Settings → Actions → Secrets에 아래 스테이징 Secret을 등록합니다. 기존 접두사 없는 이름과 `STAGING_*` 이름은 새 workflow에서 읽지 않습니다.
|
||||
|
||||
| 이름 | 종류 | 값 |
|
||||
|---|---|---|
|
||||
| `CLOUDFLARE_API_TOKEN` | Secret | Workers Scripts Edit + Workers R2 Storage Edit 권한의 Cloudflare API Token |
|
||||
| `SESSION_SECRET` | Secret | `openssl rand -hex 32`로 생성한 세션 서명키 |
|
||||
| `ABC_API_KEY` | Secret | 프로젝트 전용 ABC UserFeedback API Key |
|
||||
| `STG_CLOUDFLARE_API_TOKEN` | Secret | 스테이징 Worker·R2 배포용 Cloudflare API Token |
|
||||
| `STG_SESSION_SECRET` | Secret | 스테이징 세션 서명키. 기존 서명키를 유지하면 기존 로그인 세션도 유지 |
|
||||
| `STG_ABC_API_KEY` | Secret | 스테이징 ABC project/channel API Key |
|
||||
| `STG_ORG_CONTEXT_KEY_ID` | Secret | 스테이징 조직 API Key ID |
|
||||
| `STG_ORG_CONTEXT_KEY_SECRET` | Secret | 스테이징 조직 API Key Secret |
|
||||
|
||||
스테이징 일반 변수는 기존 설정에서 확인한 값으로 아래와 같이 분리한다. 운영 프로젝트·채널 ID를 스테이징에 복사하지 않는다.
|
||||
|
||||
| Variable | 스테이징 값 |
|
||||
|---|---|
|
||||
| `STG_AUTH_CLIENT_ID` | `e16898a7-acd2-420a-a224-cdcd89ff66cd` |
|
||||
| `STG_AUTH_ISSUER` | `https://sso.hmac.kr/oidc` |
|
||||
| `STG_ABC_API_BASE_URL` | `https://feedback.hmac.kr` |
|
||||
| `STG_ABC_PROJECT_ID` | `01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72` |
|
||||
| `STG_ABC_CHANNEL_ID` | `01a0ae40-51c2-7647-a93c-0249b3759777` |
|
||||
| `STG_ORG_CONTEXT_ENABLED` | `true` |
|
||||
| `STG_ORG_CONTEXT_API_URL` | `https://sadmin.hmac.kr/api/v1/integrations/org-context` |
|
||||
| `STG_ORG_CONTEXT_SCOPE_MODE` | `SCOPED` |
|
||||
| `STG_ORG_CONTEXT_EXPOSURE` | `identifiers` |
|
||||
| `STG_ORG_CONTEXT_TENANT_IDS` | 기존 스테이징 기준 tenant 7개 JSON 배열 |
|
||||
| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | `300` |
|
||||
|
||||
workflow가 `STG_`를 제거한 Worker/CLI 환경 이름으로 매핑한다. `scripts/deploy-worker.mjs`는 필수 API·SSO 값이 비었을 때 다른 환경으로 대체하지 않고 중단한다. `AUTH_ISSUER`로 authorization·token·userinfo 주소를 구성한다. `--validate-only`와 `--dry-run`은 Worker·Secret을 변경하지 않는다.
|
||||
|
||||
2026-11-11 확인: 일반 변수 11개를 이 저장소의 Gitea Actions Variables에 등록하고 값 일치를 확인했다. `STG_*` Secret 5개는 사용자가 등록한다. 현재 원격 workflow가 사용하는 기존 이름은 새 Secret 등록 및 workflow 반영 이후 정리한다. 운영 등록 목록은 [production 안내](docs/qa-production-pipeline.md)를 따른다.
|
||||
|
||||
`CLOUDFLARE_ACCOUNT_ID`는 secret으로 등록할 필요가 없습니다. `wrangler.toml`에 `81fa2d48964d31dd0da9558f9ce601d1`로 설정되어 있습니다.
|
||||
|
||||
@@ -97,7 +121,8 @@ Cloudflare API Token에는 최소한 다음 권한이 필요합니다.
|
||||
- Account → Workers Scripts → Edit
|
||||
- Account → Workers R2 Storage → Edit
|
||||
- Account → Account Settings → Read
|
||||
- Custom Domain route를 Actions에서 변경할 경우 Zone → Workers Routes → Edit
|
||||
|
||||
스테이징 `qa-test.baroncs.co.kr`과 운영 `qa.baroncs.co.kr`의 도메인 연결은 Cloudflare 대시보드에서 관리합니다. 두 Wrangler 설정 모두 `route`/`routes`를 생략하고 `workers_dev=false`로 지정해 기존 연결을 유지합니다. 배포 시 Zone 라우팅 API를 호출하지 않으므로 이 작업에 Zone 권한을 추가할 필요가 없습니다. [Cloudflare 설정 안내](https://developers.cloudflare.com/workers/wrangler/configuration/#source-of-truth)
|
||||
|
||||
`.gitea/workflows/deploy.yml`은 `main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다.
|
||||
|
||||
@@ -110,19 +135,23 @@ Gitea의 스테이징 설정을 `.gitea/workflows/deploy.yml`에서 Worker 런
|
||||
|
||||
| Gitea | 위치 | Worker 런타임 |
|
||||
|---|---|---|
|
||||
| `STAGING_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) |
|
||||
| `STAGING_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) |
|
||||
| `STAGING_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` |
|
||||
| `STAGING_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` |
|
||||
| `STAGING_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` |
|
||||
| `STAGING_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` |
|
||||
| `STAGING_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
|
||||
| `STAGING_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
|
||||
| `STG_ORG_CONTEXT_KEY_ID` | Secrets | `ORG_CONTEXT_KEY_ID` (Secret) |
|
||||
| `STG_ORG_CONTEXT_KEY_SECRET` | Secrets | `ORG_CONTEXT_KEY_SECRET` (Secret) |
|
||||
| `STG_ORG_CONTEXT_ENABLED` | Variables | `ORG_CONTEXT_ENABLED` |
|
||||
| `STG_ORG_CONTEXT_API_URL` | Variables | `ORG_CONTEXT_API_URL` |
|
||||
| `STG_ORG_CONTEXT_SCOPE_MODE` | Variables | `ORG_CONTEXT_SCOPE_MODE` |
|
||||
| `STG_ORG_CONTEXT_EXPOSURE` | Variables | `ORG_CONTEXT_EXPOSURE` |
|
||||
| `STG_ORG_CONTEXT_TENANT_IDS` | Variables | `ORG_CONTEXT_TENANT_IDS` |
|
||||
| `STG_ORG_CONTEXT_CACHE_TTL_SECONDS` | Variables, 선택 | `ORG_CONTEXT_CACHE_TTL_SECONDS` (기본 300) |
|
||||
|
||||
조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 페이지 조회 타임아웃은 요청당 5초입니다. 배포 전 `npm run org:verify`는 요청당 15초를 허용하고, 타임아웃·통신 실패·HTTP 429/502/503/504에 한해 2초 간격으로 최대 3회 시도합니다. 인증 실패·범위 불일치·구성원 누락은 재시도 없이 검증 실패로 처리합니다.
|
||||
|
||||
기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다.
|
||||
|
||||
배포 전 검증에서 HTTP 401/403이 발생하면 요청한 API 주소·기준 tenant UUID·OperationID `baron.org-context.read`·exposure `identifiers`를 함께 출력합니다. 키 원문과 API 응답 본문은 출력하지 않습니다. 403이면 발급 서버의 API 키 관리에서 이 작업·노출 단계·해당 tenant subtree 조회 범위를 확인합니다. 여러 기준 tenant 중 하나만 거부돼도 전체 검증은 실패하며, 권한 밖 조직을 임의로 제외하거나 검증을 건너뛰지 않습니다.
|
||||
|
||||
서버가 제공하는 오류 중 문서에 정의된 코드만 `upstreamCode`로 출력합니다. `credential_operation_mismatch`는 허용 작업, `credential_scope_exceeded`는 조직 범위, `credential_exposure_exceeded`는 노출 단계 거절입니다. 서버가 반환한 허용/요청 노출 단계도 정해진 enum에 해당할 때만 표시합니다. HTML 응답이나 Cloudflare challenge 여부는 `responseKind`, `proxyChallenge`로 구분합니다. 알 수 없는 코드·오류 메시지·HTML 원문은 출력하지 않습니다.
|
||||
|
||||
Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 컨텍스트·대표 소속을 선택하고, 기존 requester ID·tenant ID·권한은 유지합니다. 새 문의는 `requester_affiliation`에 회사, `requester_department`에 부서를 저장합니다. JSON과 첨부 multipart에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다.
|
||||
|
||||
조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. 로그인 후 `/auth/session`의 `user.orgContextStatus=unavailable`이면 `user.orgContextError`에서 허용된 오류 코드만 확인할 수 있습니다. `org_context_missing_credentials`는 Worker의 조직 API 키 누락, `org_context_http_401`은 API 인증 거부, `org_context_http_403`은 API 접근 거부, `org_context_timeout`은 요청 시간 초과를 뜻합니다. 알 수 없는 오류는 `org_context_unavailable`로 표시하며 키·원본 오류 메시지·조직 디렉터리는 응답에 넣지 않습니다. 조회가 정상화되면 이전 `orgContextError`는 제거됩니다. HTTP 200이나 요청 로그의 `outcome=ok`만으로 조직 조회 성공을 판단하지 않습니다.
|
||||
|
||||
@@ -8,10 +8,21 @@
|
||||
.inquiry-hero__copy { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #fff; text-align: center; padding: 40px 24px; }
|
||||
.inquiry-hero h1 { font-size: 70px; font-weight: 700; line-height: 1.4; letter-spacing: -0.04em; }
|
||||
.inquiry-hero p { font-size: 22px; line-height: 1.6; }
|
||||
.inquiry-account { position: absolute; top: 24px; right: 32px; z-index: 3; color: #fff; }
|
||||
.inquiry-account summary { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border: 1px solid #ffffff99; border-radius: 50%; background: #0b274366; cursor: pointer; list-style: none; }
|
||||
.inquiry-account summary::-webkit-details-marker { display: none; }
|
||||
.inquiry-account summary:hover, .inquiry-account[open] summary { background: #0b2743b3; }
|
||||
.inquiry-account summary:focus-visible { outline-color: #fff; }
|
||||
.inquiry-account svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
|
||||
.inquiry-account__panel { position: absolute; top: 54px; right: 0; width: 192px; padding: 8px; border: 1px solid #e5e9ef; border-radius: 10px; background: #fff; color: #26374f; box-shadow: 0 8px 28px #0003; }
|
||||
.inquiry-hero .inquiry-account__panel p { overflow: hidden; padding: 10px 12px; color: #697386; font-size: 13px; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.inquiry-account__panel a { display: flex; align-items: center; gap: 8px; min-height: 44px; padding: 10px 12px; border-radius: 6px; font-size: 14px; }
|
||||
.inquiry-account__panel a:hover { background: #edf3fd; color: #164b91; }
|
||||
.inquiry-account__panel a svg { width: 18px; height: 18px; }
|
||||
.inquiry-workspace { position: relative; z-index: 1; width: min(1248px, 100%); padding: 0 24px; margin: -98px auto 120px; }
|
||||
.inquiry-tabs-shell { --inquiry-tab-curve: 28px; padding: 19px 32px 0; border-radius: 48px 48px 0 0; background: linear-gradient(180deg, #174b64a6, #082f43d9); -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px); box-shadow: inset 0 1px 0 #74c7f4b3, inset 0 0 0 1px #5fadd666, inset 0 8px 16px #ffffff12, 0 -18px 12px #0a2f4843; }
|
||||
.inquiry-tabs { display: flex; gap: 60px; position: relative; isolation: isolate; }
|
||||
.inquiry-tabs button { position: relative; flex: 1; min-width: 0; min-height: 76px; padding: 8px 12px 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 34px 34px 0 0; color: #b9d4e0a6; background: transparent; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; }
|
||||
.inquiry-tabs { display: flex; justify-content: center; gap: 60px; position: relative; isolation: isolate; }
|
||||
.inquiry-tabs button { position: relative; flex: 0 1 340px; min-width: 0; min-height: 76px; padding: 8px 12px 10px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 4px; border: 0; border-radius: 34px 34px 0 0; color: #b9d4e0a6; background: transparent; cursor: pointer; transition: background-color 0.2s ease, color 0.2s ease; }
|
||||
.inquiry-tabs button:not([aria-selected="true"]):hover { color: #d6f0ff; background: #ffffff12; }
|
||||
.inquiry-tabs [aria-selected="true"] { z-index: 1; color: #00a2ef; background: linear-gradient(180deg, #e8f8ff 0%, #fff 58%); box-shadow: inset 0 2px 0 #ffffffb3, 0 -3px 10px #163f5b08; }
|
||||
/* Concave lower corners join the selected tab to the white board beneath it. */
|
||||
@@ -116,10 +127,11 @@ html.inquiry-modal-open body { overflow: hidden !important; }
|
||||
.inquiry-hero { --inquiry-hero-shade: rgba(5, 24, 44, 0.38); height: 340px; background-position: 22% center; }
|
||||
.inquiry-hero h1 { font-size: 48px; }
|
||||
.inquiry-hero p { font-size: 17px; }
|
||||
.inquiry-account { top: 16px; right: 16px; }
|
||||
.inquiry-workspace { margin: -96px auto 72px; padding: 0 16px; }
|
||||
.inquiry-tabs-shell { --inquiry-tab-curve: 13px; padding: 12px 13px 0; border-radius: 30px 30px 0 0; }
|
||||
.inquiry-tabs { gap: 5px; }
|
||||
.inquiry-tabs button { min-height: 84px; padding: 8px 4px 12px; gap: 4px; border-radius: 22px 22px 0 0; }
|
||||
.inquiry-tabs button { flex-basis: 200px; min-height: 84px; padding: 8px 4px 12px; gap: 4px; border-radius: 22px 22px 0 0; }
|
||||
.inquiry-tab-logo { width: 145px; max-width: 100%; height: 28px; }
|
||||
.inquiry-tab-logo img { height: auto; max-height: 28px; }
|
||||
.inquiry-tabs [data-service="egbim"] img { width: 49px; height: 25px; }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
| --- | --- |
|
||||
| 작성·상세 표시 | 목록 위에 뜨는 페이지 내부 모달로 전환. 원본의 `<dialog>` 방식 기준 |
|
||||
| 기존 주소 | 기존 작성·상세 주소 연결 유지 |
|
||||
| 소프트웨어 | EG-BIM 우선 적용. TOVA·GAIA 탭은 준비중 표시 |
|
||||
| 소프트웨어 | EG-BIM 탭만 가운데 표시. TOVA·GAIA 탭은 오픈 전까지 숨김 |
|
||||
| 디자인 적용 범위 | 문의 화면·작성·상세 영역. 홈페이지 헤더·전체 메뉴·푸터는 제외 |
|
||||
| 입력 중 닫기 | 입력 변경이 있으면 폐기 여부를 확인한 뒤 닫기 |
|
||||
| 원격지원 | `https://939.co.kr/saman/` 활성화. 원본처럼 새 창으로 열기 |
|
||||
@@ -27,7 +27,7 @@
|
||||
| `SW문의_소스패키지/assets/js/inquiry.js` | 예시 게시물·공지 생성, 모달 열기/닫기, 미리보기 등록 안내 | 모달 UX를 참고하고 실제 데이터 처리는 기존 Q&A 로직으로 연결 |
|
||||
| `SW문의_소스패키지/assets/js/inquiry-copy.js` | 예시 데이터·상태명·KO/EN 미리보기 문구 | 실제 서비스 문구와 상태명으로 정리 |
|
||||
| `SW문의_소스패키지/assets/img/inquiry-hero.webp` | 스크린샷의 상단 배경 이미지 | 해당 자산 사용 |
|
||||
| `SW문의_소스패키지/ko/{egbim,tova,gaia}/img/` | 제품 로고와 모바일용 EG-BIM 심볼 | 활성·준비중 탭에 사용 |
|
||||
| `SW문의_소스패키지/ko/{egbim,tova,gaia}/img/` | 제품 로고와 모바일용 EG-BIM 심볼 | EG-BIM 탭과 추후 오픈할 숨긴 탭의 자산으로 보존 |
|
||||
| `SW문의_소스패키지/assets/js/common.js`, `baron-sso-*.js`, `_include/` | 홈페이지 메뉴·헤더·푸터와 별도 SSO 흐름에 의존 | Q&A의 기존 Worker SSO 유지. 필요한 디자인 자산만 반영 |
|
||||
| `egbim/app.js`, `src/index.js` | 실제 API·SSO·첨부·댓글·검색·작성자 버튼 처리 | 기능 이전의 기준 |
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- 반투명 배경의 제품 탭과 선택된 EG-BIM 탭의 곡선·로고·색상을 반영한다.
|
||||
- 홈페이지 헤더를 제외하므로 원본의 헤더 높이 보정용 상단 여백을 함께 조정한다.
|
||||
- 문의 내역 제목, 원격지원 버튼, 둥근 분류 필터·검색창, 표·페이지 이동·문의등록 버튼을 맞춘다.
|
||||
- TOVA·GAIA는 준비중임을 표시하고 실제 목록 조회·등록 대상으로 선택되지 않도록 한다.
|
||||
- TOVA·GAIA는 오픈 전까지 숨기고 EG-BIM 탭을 가운데 배치한다.
|
||||
- 실제 문의 데이터와 현행 검색·상태 표시를 사용한다. 예시 공지·예시 고객 데이터·미리보기 문구는 배포 화면에 넣지 않는다.
|
||||
|
||||
### 작성 모달
|
||||
@@ -131,7 +131,7 @@ flowchart LR
|
||||
| 댓글 수정·삭제 | `PATCH/DELETE /api/feedbacks/{id}/comments/{commentId}` | 실제 댓글 ID와 작성자 연결 |
|
||||
| 완료 확인 | `POST /api/feedbacks/{id}/completion-confirmation` | 기존 완료 확인 처리 |
|
||||
|
||||
이번 범위에서 새 DB나 별도 업로드 API를 도입할 필요는 없다. TOVA·GAIA를 준비중으로 두므로 제품별 API 키·프로젝트·채널 설정 확대도 이번 작업의 선행 조건이 아니다.
|
||||
이번 범위에서 새 DB나 별도 업로드 API를 도입할 필요는 없다. TOVA·GAIA를 숨겨 두므로 제품별 API 키·프로젝트·채널 설정 확대도 이번 작업의 선행 조건이 아니다.
|
||||
|
||||
## 7. 단계별 작업 목록
|
||||
|
||||
@@ -149,7 +149,7 @@ flowchart LR
|
||||
- [x] `UI-01` 문의 전용 스타일 범위를 정하고 기존 공통 버튼·폼·레이아웃과의 충돌을 정리한다.
|
||||
- [x] `UI-02` 히어로 이미지·제품 로고·필요한 폰트만 배포 자산으로 정리하고 상대 경로를 맞춘다.
|
||||
- [x] `UI-03` 목록의 히어로·곡선 탭·필터·검색창·표·페이지 이동·등록 버튼을 원본 기준으로 구성한다.
|
||||
- [x] `UI-04` TOVA·GAIA 준비중 표기와 접근 가능한 비활성 탭 처리를 추가한다.
|
||||
- [x] `UI-04` TOVA·GAIA를 숨기고 EG-BIM 탭을 데스크톱·모바일에서 가운데 배치한다.
|
||||
- [x] `UI-05` 원격지원 링크를 확정 주소로 연결하고 새 창 안내와 `noopener noreferrer`를 유지한다.
|
||||
- [x] `UI-06` 실제 데이터가 없는 예시 공지·예시 댓글 수·미리보기 문구를 제거한다. 댓글 수 배지가 필요하면 실제 API 제공값을 확인하고 연결하며, 행마다 댓글 API를 추가 호출하는 방식은 피한다.
|
||||
|
||||
@@ -222,13 +222,13 @@ flowchart LR
|
||||
- [x] fuzzy 검색과 전체 페이지 수집·필터·페이지 이동을 유지한다.
|
||||
- [x] 입력 변경 후 닫기는 확인을 거치며, 닫기 취소 시 입력과 파일을 보존한다.
|
||||
- [x] 모달을 닫아도 목록 조건·위치가 유지되고 새 글·수정·삭제 결과가 반영된다.
|
||||
- [x] TOVA·GAIA 준비중 표시와 확정된 원격지원 링크가 동작한다.
|
||||
- [x] TOVA·GAIA 숨김·EG-BIM 탭 중앙 배치와 확정된 원격지원 링크가 동작한다.
|
||||
- [x] 예시 데이터·미리보기 전용 문구가 남지 않고 모바일·키보드 사용과 회귀 검증을 통과했다.
|
||||
|
||||
|
||||
## 10. 구현·검증 결과
|
||||
|
||||
변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다.
|
||||
초기 목록·모달 UI 변경은 정적 HTML/CSS/JS와 자산 업로드 MIME·검증·문서에 한정했다. 해당 작업에서 Worker·API 계약·DB·SSO 설정·기존 배포 트리거는 변경하지 않았다. 기존 주소는 프론트의 `assets/inquiry/entry.js`로 연결하며, 새 화면은 기존 API를 그대로 호출한다. 원본 패키지 전체를 서비스 자산으로 복사하지 않았다.
|
||||
|
||||
- `npm run check`, `npm test`: 기존 94개와 구형 주소 연결 4개, 총 98개 테스트 통과.
|
||||
- R2 업로드 검사: 새 하위 경로의 진입 JS와 WebP 이미지가 기존 배포 경로에 포함되며 MIME은 각각 `application/javascript`, `image/webp`다.
|
||||
@@ -254,4 +254,13 @@ CHROMIUM_EXECUTABLE_PATH=/absolute/path/to/chromium \
|
||||
npm run test:ui
|
||||
```
|
||||
|
||||
기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다.
|
||||
기본 스크린샷 출력 위치는 임시 디렉토리의 `qa-inquiry-ui/`다. `UI_SCREENSHOT_DIR`로 바꿀 수 있으며, 데스크톱·모바일 목록/작성/상세 6장과 모바일 계정 메뉴 화면을 생성한다. 실제 사용자 세션·API Key는 필요하지 않다.
|
||||
|
||||
|
||||
## 11. 후속 계정 메뉴
|
||||
|
||||
- 상단 오른쪽 사람 아이콘으로 로그인 사용자 이름·로그아웃 메뉴를 연다. 바깥 클릭·ESC로 닫는다.
|
||||
- 로그아웃은 기존 `/auth/logout`에서 Q&A 세션 쿠키를 삭제한 뒤 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 이동한다.
|
||||
- 스테이징·운영의 `AUTH_POST_LOGOUT_REDIRECT_URI`를 위 주소로 설정했다. 서버 설정의 HTTPS 목적지로 이동할 수 있게 기존 로그아웃 처리를 조정했으며 로그인 `return_url` 정책은 유지한다.
|
||||
- 중앙 SSO 세션 종료 API는 호출하지 않는다. 추가 Secret·SSO client/callback 변경은 없다.
|
||||
- 검증: 스테이징/운영 세션 쿠키 삭제·고정 목적지·쿼리 덮어쓰기 방지·잘못된 설정 처리 테스트, 브라우저 메뉴/키보드/모바일 표시 및 실제 Worker 핸들러를 통한 쿠키 삭제·목적지 이동 요청을 확인한다. 리다이렉트 목적지로의 실제 외부 접속은 Chromium 네트워크 차단으로 막는다. 기본 테스트 총 106개와 브라우저 회귀 검사를 통과했다.
|
||||
@@ -1,6 +1,6 @@
|
||||
# EG-BIM Q&A 로그인·화면·저장 흐름
|
||||
|
||||
기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 이번 UI 개편은 프론트에 한정하며 Worker API·DB·SSO 설정을 변경하지 않는다.
|
||||
기준일: 2026-11-02. 스테이징 `qa-test.baroncs.co.kr`, 운영 `qa.baroncs.co.kr`의 현재 구조를 설명한다. 목록·모달 UI는 기존 API·DB·SSO 로그인 흐름을 유지한다. 후속 계정 메뉴 작업에서는 기존 로그아웃 경로의 이동 목적지를 홈페이지 문의 페이지로 연결했다.
|
||||
|
||||
## 로그인과 화면 진입
|
||||
|
||||
@@ -36,6 +36,10 @@ flowchart TD
|
||||
M -->|아니오| O[모달 오류 표시·입력과 파일 유지]
|
||||
```
|
||||
|
||||
## 계정 메뉴와 로그아웃
|
||||
|
||||
상단 오른쪽 사람 아이콘을 누르면 로그인 사용자 이름과 로그아웃 링크가 나온다. 바깥 클릭이나 ESC로 메뉴를 닫을 수 있다. `/auth/logout`은 현재 환경의 Q&A 세션 쿠키를 만료시키고 `https://test.baroncs.co.kr/ko/sw-inquiry.html`로 302 이동한다. 목적지는 스테이징·운영 Wrangler 설정의 `AUTH_POST_LOGOUT_REDIRECT_URI`에서 읽으며 사용자 쿼리로 덮어쓸 수 없다. 중앙 SSO 세션 종료 API는 호출하지 않는다.
|
||||
|
||||
## 작성·첨부 저장
|
||||
|
||||
브라우저는 기존 UUID v7 생성 방식으로 `feedbackId`를 만들고 아래 필드를 보낸다. 회사·부서·작성자·이메일 칸은 읽기 전용이며, 서버가 기존 세션·조직 조회 결과에서 작성자 정보를 결정한다.
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
### 2. SSO 운영 등록
|
||||
|
||||
- [x] 운영 callback URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/auth/callback`. 사용자 완료 보고 기준이다.
|
||||
- [x] 로그아웃 후 이동 URI를 SSO에 등록했다: `https://qa.baroncs.co.kr/`. 사용자 완료 보고 기준이다.
|
||||
- [x] 초기 SSO 등록의 로그아웃 후 URI는 `https://qa.baroncs.co.kr/`로 등록했다. 사용자 완료 보고 기준이다. 현재 계정 메뉴의 Q&A 로그아웃은 Worker에서 세션을 삭제하고 별도 설정된 홈페이지 문의 주소로 직접 이동한다.
|
||||
- [x] 운영 SSO 설정에 맞는 `PROD_AUTH_CLIENT_ID` 등 관련 변수 등록을 완료했다. 실제 로그인 동작 확인은 운영 배포 후 수행한다.
|
||||
- [ ] 운영 도메인에서 실제 사내 사용자로 로그인·로그아웃할 수 있는지 확인한다.
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
| Worker 이름 | `baron-qa-gateway-prod` |
|
||||
| Worker custom domain | `qa.baroncs.co.kr` |
|
||||
| AUTH redirect URI | `https://qa.baroncs.co.kr/auth/callback` |
|
||||
| AUTH post logout URI | `https://qa.baroncs.co.kr/` |
|
||||
| Q&A 로그아웃 후 이동 URI | `https://test.baroncs.co.kr/ko/sw-inquiry.html` (`AUTH_POST_LOGOUT_REDIRECT_URI`) |
|
||||
| AUTH client ID | 운영 SSO client ID |
|
||||
| ABC API base URL | `https://feedback.hmac.kr` 또는 운영 API 주소 |
|
||||
| ABC project ID / channel ID | 운영 대상 ID |
|
||||
|
||||
@@ -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`을 허용하고, 로그아웃 후 이동 URI로 `https://qa.baroncs.co.kr/`을 확인한다. 등록된 SSO client나 ABC project/channel이 운영 대상인지 확정한 후 배포한다.
|
||||
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 경로는 보관한다.
|
||||
|
||||
@@ -501,6 +501,18 @@
|
||||
console.error('[QA] SSO session load failed:', error);
|
||||
return getAuthUser();
|
||||
}).then(function (user) { renderAuth(user); return user; });
|
||||
const accountMenu = qs('#accountMenu');
|
||||
if (accountMenu) {
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!accountMenu.contains(event.target)) accountMenu.open = false;
|
||||
});
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && accountMenu.open) {
|
||||
accountMenu.open = false;
|
||||
qs('#accountButton').focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
const menuButton = qs('.menu-button');
|
||||
const nav = qs('.global-nav');
|
||||
if (menuButton && nav) {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W Q&A | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
|
||||
+14
-5
@@ -1,9 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="EG-BIM 소프트웨어 문의"><title>S/W 문의 | 바론컨설턴트</title><link rel="stylesheet" href="../assets/styles.css?v=20261102-ui-1"><link rel="stylesheet" href="../assets/inquiry/inquiry.css?v=20261102-ui-1"></head><body>
|
||||
<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="description" content="EG-BIM 소프트웨어 문의"><title>S/W Q&A | 바론컨설턴트</title><link rel="stylesheet" href="../assets/styles.css?v=20261102-ui-1"><link rel="stylesheet" href="../assets/inquiry/inquiry.css?v=20261102-ui-3"></head><body>
|
||||
<main class="inquiry-page">
|
||||
<section class="inquiry-hero" aria-labelledby="inquiry-title">
|
||||
<details class="inquiry-account" id="accountMenu">
|
||||
<summary aria-label="내 계정" id="accountButton" aria-controls="accountPanel">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></svg>
|
||||
</summary>
|
||||
<div class="inquiry-account__panel" id="accountPanel">
|
||||
<p data-auth-name>로그인 확인 중</p>
|
||||
<a href="/auth/logout" id="logoutLink"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M10 4H4v16h6M14 8l4 4-4 4m-5-4h12"/></svg>로그아웃</a>
|
||||
</div>
|
||||
</details>
|
||||
<div class="inquiry-hero__copy">
|
||||
<h1 id="inquiry-title">S/W 문의</h1>
|
||||
<h1 id="inquiry-title">S/W Q&A</h1>
|
||||
<p>소프트웨어 관련 문의하기</p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -15,11 +24,11 @@
|
||||
<span class="inquiry-tab-description">BIM 모델</span>
|
||||
<picture class="inquiry-tab-logo"><source media="(max-width: 600px)" srcset="../assets/inquiry/egbim-symbol.svg" width="49" height="25" /><img src="../assets/inquiry/egbim.svg" alt="EG-BIM" width="150" height="26" /></picture>
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-tova" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="tova" aria-label="TOVA 문의">
|
||||
<button type="button" role="tab" id="tab-tova" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled hidden tabindex="-1" data-service="tova" aria-label="TOVA 문의">
|
||||
<span class="inquiry-tab-description">통합 교통 수요분석 <span class="inquiry-coming-soon">준비중</span></span>
|
||||
<span class="inquiry-tab-logo"><img src="../assets/inquiry/tova.svg" alt="TOVA" width="104" height="26" /></span>
|
||||
</button>
|
||||
<button type="button" role="tab" id="tab-gaia" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled tabindex="-1" data-service="gaia" aria-label="GAIA 문의">
|
||||
<button type="button" role="tab" id="tab-gaia" aria-controls="inquiry-panel" aria-selected="false" aria-disabled="true" disabled hidden tabindex="-1" data-service="gaia" aria-label="GAIA 문의">
|
||||
<span class="inquiry-tab-description">지반조사 <span class="inquiry-coming-soon">준비중</span></span>
|
||||
<span class="inquiry-tab-logo"><img src="../assets/inquiry/gaia.svg" alt="GAIA" width="82" height="33" /></span>
|
||||
</button>
|
||||
@@ -204,5 +213,5 @@
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261102-ui-1"></script>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261102-ui-3"></script>
|
||||
</body></html>
|
||||
+1
-1
@@ -1 +1 @@
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W 문의 | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
|
||||
<!doctype html><html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>S/W Q&A | 바론컨설턴트</title><script src="../assets/inquiry/entry.js?v=20261102-ui-1" defer></script></head><body><p>문의 화면으로 이동합니다. <a href="./">문의 목록</a></p></body></html>
|
||||
+3
-2
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { deploymentOrgSettings } from './org-context-settings.mjs';
|
||||
import { stagingSettings } from './staging-settings.mjs';
|
||||
|
||||
const worker = 'baron-qa-gateway-test';
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const validateOnly = process.argv.includes('--validate-only');
|
||||
function execute(args, input) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn('npx', ['wrangler', ...args], {
|
||||
@@ -26,13 +27,16 @@ function execute(args, input) {
|
||||
});
|
||||
}
|
||||
try {
|
||||
const { vars, secrets } = deploymentOrgSettings(process.env);
|
||||
if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets));
|
||||
const args = ['deploy', '--name', worker];
|
||||
if (dryRun) args.push('--dry-run');
|
||||
for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value);
|
||||
await execute(args);
|
||||
} catch {
|
||||
console.error('Worker deployment failed. Check configuration and preceding Wrangler diagnostics.');
|
||||
if (process.argv.slice(2).some((option) => !['--dry-run', '--validate-only'].includes(option))) throw new Error('Unknown staging deployment option');
|
||||
const { vars, secrets } = stagingSettings(process.env);
|
||||
if (!validateOnly) {
|
||||
if (!dryRun && Object.keys(secrets).length) await execute(['secret', 'bulk', '--name', worker], JSON.stringify(secrets));
|
||||
const args = ['deploy', '--config', 'wrangler.toml', '--name', worker];
|
||||
if (dryRun) args.push('--dry-run');
|
||||
for (const [name, value] of Object.entries(vars)) args.push('--var', name + ':' + value);
|
||||
await execute(args);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error.message || 'Worker deployment failed. Check configuration and preceding Wrangler diagnostics.');
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { deploymentOrgSettings } from './org-context-settings.mjs';
|
||||
|
||||
function required(env, name) {
|
||||
const value = String(env[name] || '').trim();
|
||||
if (!value) throw new Error('STG_' + name + ' is required for staging');
|
||||
return value;
|
||||
}
|
||||
|
||||
function httpsUrl(value, name, originOnly = false) {
|
||||
let url;
|
||||
try { url = new URL(value); } catch { throw new Error('STG_' + name + ' must be an HTTPS URL'); }
|
||||
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash || (originOnly && url.pathname !== '/')) {
|
||||
throw new Error('STG_' + name + ' must be an HTTPS ' + (originOnly ? 'origin' : 'issuer URL'));
|
||||
}
|
||||
return originOnly ? url.origin : url.origin + url.pathname.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function stagingSettings(env) {
|
||||
const organization = deploymentOrgSettings({ ...env, ORG_CONTEXT_ENABLED: required(env, 'ORG_CONTEXT_ENABLED') });
|
||||
const issuer = httpsUrl(required(env, 'AUTH_ISSUER'), 'AUTH_ISSUER');
|
||||
const vars = {
|
||||
...organization.vars,
|
||||
AUTH_CLIENT_ID: required(env, 'AUTH_CLIENT_ID'),
|
||||
AUTH_AUTHORIZE_URL: issuer + '/oauth2/auth',
|
||||
AUTH_TOKEN_URL: issuer + '/oauth2/token',
|
||||
AUTH_USERINFO_URL: issuer + '/userinfo',
|
||||
ABC_API_BASE_URL: httpsUrl(required(env, 'ABC_API_BASE_URL'), 'ABC_API_BASE_URL', true),
|
||||
ABC_PROJECT_ID: required(env, 'ABC_PROJECT_ID'),
|
||||
ABC_CHANNEL_ID: required(env, 'ABC_CHANNEL_ID')
|
||||
};
|
||||
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('STG_' + name + ' must be a UUID');
|
||||
}
|
||||
return { vars, secrets: organization.secrets };
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -3,16 +3,56 @@ import { deploymentOrgSettings } from './org-context-settings.mjs';
|
||||
import { setTimeout as wait } from 'node:timers/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
// Only documented machine codes and enum values may be copied from an upstream error.
|
||||
const accessErrorCodes = new Set([
|
||||
'credential_operation_not_available', 'credential_operation_mismatch',
|
||||
'credential_scope_exceeded', 'credential_exposure_exceeded',
|
||||
'integration_credential_not_allowed', 'integration_credential_required',
|
||||
'invalid_integration_credential', 'integration_credential_expired', 'credential_conflict'
|
||||
]);
|
||||
const exposures = new Set(['none', 'structure', 'members', 'identifiers']);
|
||||
|
||||
async function accessResponseDiagnostic(response) {
|
||||
const type = response.headers.get('content-type') || '';
|
||||
const responseKind = /json/i.test(type) ? 'json' : /html/i.test(type) ? 'html' : 'other';
|
||||
let body;
|
||||
if (responseKind === 'json') {
|
||||
try { body = await response.clone().json(); } catch {}
|
||||
}
|
||||
const code = body?.code || body?.error;
|
||||
return {
|
||||
responseKind,
|
||||
upstreamCode: accessErrorCodes.has(code) ? code : 'unrecognized',
|
||||
grantedExposure: exposures.has(body?.details?.grantedExposure) ? body.details.grantedExposure : undefined,
|
||||
requiredExposure: exposures.has(body?.details?.requiredExposure) ? body.details.requiredExposure : undefined,
|
||||
proxyChallenge: response.headers.get('cf-mitigated') === 'challenge'
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyOrganization(env = process.env, { fetchImpl = fetch, pause = wait, log = console.log } = {}) {
|
||||
const { vars, secrets } = deploymentOrgSettings(env);
|
||||
if (vars.ORG_CONTEXT_ENABLED !== 'true') {
|
||||
log('Organization integration disabled; existing behavior retained.');
|
||||
return;
|
||||
}
|
||||
const checkedFetch = async (url, options) => {
|
||||
const response = await fetchImpl(url, options);
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
const request = new URL(url);
|
||||
log(JSON.stringify({
|
||||
status: 'organization_access_denied', httpStatus: response.status,
|
||||
endpoint: request.origin + request.pathname,
|
||||
tenantId: request.searchParams.get('tenantId'),
|
||||
operation: 'baron.org-context.read', exposure: 'identifiers',
|
||||
...await accessResponseDiagnostic(response)
|
||||
}));
|
||||
}
|
||||
return response;
|
||||
};
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
// CI can wait for a slow upstream without extending the page request timeout.
|
||||
const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl, timeoutMs: 15000 });
|
||||
const directory = await getOrgDirectory({ ...vars, ...secrets }, { fetchImpl: checkedFetch, timeoutMs: 15000 });
|
||||
const summary = {
|
||||
status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length,
|
||||
tenantCount: directory.tenantCount, userCount: directory.byUser.size,
|
||||
|
||||
+7
-2
@@ -766,6 +766,11 @@ function isUuidv7(value) {
|
||||
|
||||
function logout(request, env) {
|
||||
const url = new URL(request.url);
|
||||
const target = safeReturnUrl(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
|
||||
return redirect(new URL(target, url.origin).toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
|
||||
let target = new URL('/', url.origin);
|
||||
try {
|
||||
// External destinations come only from trusted deployment config, never request parameters.
|
||||
const configured = new URL(env.AUTH_POST_LOGOUT_REDIRECT_URI || '/', url.origin);
|
||||
if ((configured.protocol === 'https:' || configured.origin === url.origin) && !configured.username && !configured.password) target = configured;
|
||||
} catch (error) {}
|
||||
return redirect(target.toString(), { 'set-cookie': cookie(env.SESSION_COOKIE_NAME, '', 0, { delete: true }) });
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import {createRequire} from 'node:module';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import worker from '../../src/index.js';
|
||||
const require = createRequire(import.meta.url);
|
||||
const {chromium} = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const root = fileURLToPath(new URL('../../', import.meta.url)).replace(/\/$/, '');
|
||||
@@ -17,7 +18,12 @@ await mkdir(images,{recursive:true});
|
||||
const errors = [];
|
||||
try {
|
||||
const context = await browser.newContext({viewport:{width:1900,height:1000}});
|
||||
await context.addCookies([{name:'fixture_session',value:'existing-session',url:'https://qa.example.test',httpOnly:true,secure:true}]);
|
||||
const page = await context.newPage();page.setDefaultTimeout(8000);page.setDefaultNavigationTimeout(8000);
|
||||
// Redirect continuations bypass Playwright route handlers; block the real destinations in Chromium.
|
||||
const network = await context.newCDPSession(page);
|
||||
await network.send('Network.enable');
|
||||
await network.send('Network.setBlockedURLs',{urls:['https://test.baroncs.co.kr/*','https://sso.hmac.kr/*']});
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
const records = Array.from({length:15},(_,i)=>({id:id(i+1),requester_id:OWNER,requester_name:'송대일',requester_affiliation:'총괄기획&기술개발센터',requester_department:'IS3',title:['PDF 출력 시 이미지 표시 관련 문의','EG-BIM 로그인 오류 문의','모델링 기능 개선 요청'][i%3] + (i>2?' '+(i+1):''),contents:'EG-BIM 이용 중 궁금한 사항을 문의합니다.\n\n이 영역에 문의 내용과 첨부파일, 답변이 표시됩니다.',Category:['ERROR_QNA','GENERAL_QNA','IMPROVEMENT_QNA'][i%3],created_at:'2026-11-02T01:14:00Z',feedback_status:['NEW','RECEIVED','IN_PROGRESS','PENDING','COMPLETED'][i%5],is_secret:i===0,attachments:i===0?[{url:'/fixture/manual.pdf',original_file_name:'화면 설명.pdf'}]:[]}));
|
||||
const comments = new Map(records.map(r=>[r.id,[{id:id(101),author_id:OWNER,author_name:'송대일',content:'확인 부탁드립니다.',created_at:'2026-11-02T02:00:00Z'},{id:id(102),author_id:'other',author_name:'고객지원',content:'접수되었습니다.',created_at:'2026-11-02T02:15:00Z'}]]));
|
||||
@@ -26,6 +32,10 @@ try {
|
||||
await page.route('**/*',async route=>{
|
||||
const request = route.request(); const url = new URL(request.url());
|
||||
if(url.origin!=='https://qa.example.test') return route.abort();
|
||||
if(url.pathname==='/auth/logout') {
|
||||
const response=await worker.fetch(new Request(url),{SESSION_COOKIE_NAME:'fixture_session',AUTH_POST_LOGOUT_REDIRECT_URI:'https://test.baroncs.co.kr/ko/sw-inquiry.html'});
|
||||
return route.fulfill({status:response.status,headers:Object.fromEntries(response.headers),body:''});
|
||||
}
|
||||
const send = (body,status=200)=>route.fulfill({status,contentType:'application/json',body:JSON.stringify(body)});
|
||||
if(url.pathname==='/auth/session') return send({authenticated:true,user:{requesterId:OWNER,userUuid:OWNER,tenantId:id(555),name:'송대일',email:'fixture@example.test',company:'총괄기획&기술개발센터',department:'IS3'}});
|
||||
if(url.pathname.startsWith('/api/')) {
|
||||
@@ -63,7 +73,16 @@ try {
|
||||
const decide = accept => page.once('dialog',d=>accept?d.accept():d.dismiss());
|
||||
await page.goto('https://qa.example.test/egbim/');
|
||||
await page.waitForFunction(()=>document.querySelector('#resultCount').textContent==='총 15건');
|
||||
assert.equal(await page.locator('#tab-tova').isDisabled(),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/');
|
||||
assert.equal(await page.locator('#tab-tova').isVisible(),false);assert.equal(await page.locator('#tab-gaia').isVisible(),false);
|
||||
assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);assert.equal(await page.locator('.inquiry-remote').getAttribute('href'),'https://939.co.kr/saman/');
|
||||
await page.locator('#accountButton').click();
|
||||
assert.equal(await page.locator('#logoutLink').isVisible(),true);
|
||||
assert.equal(await page.locator('#accountPanel [data-auth-name]').textContent(),'송대일');
|
||||
await page.keyboard.press('Escape');
|
||||
assert.equal(await page.locator('#logoutLink').isVisible(),false);
|
||||
assert.equal(await page.locator('#accountButton').evaluate(e=>e===document.activeElement),true);
|
||||
await page.locator('#accountButton').click();await page.locator('#inquiry-title').click();
|
||||
assert.equal(await page.locator('#logoutLink').isVisible(),false);
|
||||
await page.screenshot({path:images+'/desktop-list.png',fullPage:true});
|
||||
await page.locator('#query').fill('로그임');await page.locator('#searchForm').evaluate(f=>f.requestSubmit());assert.equal(await page.locator('#qaRows tr').count(),5);
|
||||
await page.locator('#qaRows tr a').first().click();await page.waitForSelector('#detailModal[open]');await close();assert.equal(await page.locator('#query').inputValue(),'로그임');
|
||||
@@ -107,6 +126,7 @@ try {
|
||||
await page.locator('#writeButton').click();await page.locator('#category').selectOption('오류문의');await page.locator('#title').fill('JSON 등록');await page.locator('#content').fill('첨부 없이 등록');await page.locator('#qaForm').evaluate(f=>f.requestSubmit());await page.waitForFunction(()=>document.querySelector('[data-detail-title]')?.textContent==='JSON 등록');assert.match(mutations.at(-1).type,/application\/json/);assert.equal(mutations.filter(m=>m.url==='/api/feedbacks'&&m.method==='POST').length,3);await close();
|
||||
await page.setViewportSize({width:390,height:844});await page.evaluate(()=>scrollTo(0,0));await page.screenshot({path:images+'/mobile-list.png',fullPage:true});
|
||||
assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true);
|
||||
assert.equal(await page.locator('#tab-egbim').evaluate(e=>Math.abs(e.getBoundingClientRect().x+e.getBoundingClientRect().width/2-innerWidth/2)<1),true);
|
||||
await page.locator('#writeButton').click();assert.equal(await page.locator('.inquiry-form-grid').evaluate(e=>getComputedStyle(e).gridTemplateColumns.split(' ').length),1);await page.screenshot({path:images+'/mobile-write.png'});
|
||||
assert.equal(await page.locator('#composeModal').evaluate(d=>d.scrollHeight>d.clientHeight),true);await close();
|
||||
await page.locator('#qaRows tr a').first().evaluate(a=>a.click());await page.waitForSelector('#commentForm');await page.screenshot({path:images+'/mobile-detail.png'});assert.equal(await page.locator('#detailModal').evaluate(d=>d.getBoundingClientRect().right<=innerWidth),true);await close();
|
||||
@@ -115,6 +135,13 @@ try {
|
||||
assert.equal(await page.locator('#editButton').isVisible(),false);
|
||||
assert.equal(await page.locator('#commentForm').isVisible(),false);
|
||||
await close();
|
||||
await page.locator('#accountButton').click();
|
||||
assert.equal(await page.locator('#accountPanel').evaluate(e=>e.getBoundingClientRect().right<=innerWidth&&e.getBoundingClientRect().left>=0),true);
|
||||
await page.screenshot({path:images+'/mobile-account.png'});
|
||||
const logoutDestination = page.waitForRequest('https://test.baroncs.co.kr/ko/sw-inquiry.html');
|
||||
await page.locator('#logoutLink').click({noWaitAfter:true});
|
||||
await logoutDestination;
|
||||
assert.equal((await context.cookies('https://qa.example.test')).some(c=>c.name==='fixture_session'),false);
|
||||
assert.deepEqual(errors,[]);console.log(JSON.stringify({result:'PASS',mutations:mutations.length,screenshots:images}));await context.close();
|
||||
for (const scenario of ['owner', 'other', 'same-name-no-id', 'anonymous-with-stale-user', 'session-unavailable', 'delayed-session']) {
|
||||
const context = await browser.newContext();
|
||||
|
||||
@@ -84,3 +84,81 @@ test('disabled verification makes no upstream requests', async () => {
|
||||
fetchImpl: async () => assert.fail('disabled lookup'), log() {}
|
||||
});
|
||||
});
|
||||
|
||||
for (const status of [401, 403]) {
|
||||
test(`HTTP ${status} diagnostics identify the requested tenant without exposing credentials or upstream data`, async () => {
|
||||
const env = settings();
|
||||
const logs = [];
|
||||
await assert.rejects(verifyOrganization(env, {
|
||||
fetchImpl: async () => response({ error: 'private-upstream-message', name: 'private-user', phone: '+8201000000000' }, status),
|
||||
log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial')
|
||||
}), new RegExp('org_context_http_' + status));
|
||||
const diagnostic = JSON.parse(logs[0]);
|
||||
assert.equal(diagnostic.httpStatus, status);
|
||||
assert.equal(diagnostic.endpoint, 'https://sadmin.hmac.kr/api/v1/integrations/org-context');
|
||||
assert.equal(diagnostic.tenantId, ROOT);
|
||||
assert.equal(diagnostic.operation, 'baron.org-context.read');
|
||||
assert.equal(diagnostic.exposure, 'identifiers');
|
||||
for (const hidden of [env.ORG_CONTEXT_KEY_ID, env.ORG_CONTEXT_KEY_SECRET, 'private-upstream-message', 'private-user', '+8201000000000']) {
|
||||
assert.ok(!JSON.stringify(logs).includes(hidden));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('a partial directory fails and diagnostics point to the forbidden root instead of a permitted root', async () => {
|
||||
const deniedRoot = '9951229e-eea5-4376-96f4-78989bc3a506';
|
||||
const logs = [];
|
||||
await assert.rejects(verifyOrganization({ ...settings(), ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, deniedRoot]) }, {
|
||||
fetchImpl: async url => new URL(url).searchParams.get('tenantId') === deniedRoot ? response({}, 403) : response(directory()),
|
||||
log: line => logs.push(line), pause: async () => assert.fail('must not retry access denial')
|
||||
}), /org_context_http_403/);
|
||||
assert.deepEqual(logs.map(line => JSON.parse(line).tenantId), [deniedRoot]);
|
||||
});
|
||||
|
||||
for (const code of ['credential_operation_mismatch', 'credential_scope_exceeded', 'credential_exposure_exceeded']) {
|
||||
test(`access diagnostics preserve the documented ${code} reason while excluding upstream messages`, async () => {
|
||||
const logs = [];
|
||||
await assert.rejects(verifyOrganization(settings(), {
|
||||
fetchImpl: async () => response({ code, message: 'private-server-message', details: {
|
||||
grantedExposure: 'members', requiredExposure: 'identifiers', secret: 'private-value'
|
||||
} }, 403),
|
||||
log: line => logs.push(line)
|
||||
}), /org_context_http_403/);
|
||||
const diagnostic = JSON.parse(logs[0]);
|
||||
assert.equal(diagnostic.upstreamCode, code);
|
||||
assert.equal(diagnostic.grantedExposure, 'members');
|
||||
assert.equal(diagnostic.requiredExposure, 'identifiers');
|
||||
assert.ok(!logs[0].includes('private-server-message'));
|
||||
assert.ok(!logs[0].includes('private-value'));
|
||||
});
|
||||
}
|
||||
|
||||
test('unrecognized codes and invalid enum values cannot expose private upstream strings', async () => {
|
||||
const logs = [];
|
||||
await assert.rejects(verifyOrganization(settings(), {
|
||||
fetchImpl: async () => response({ code: 'private-code', details: {
|
||||
grantedExposure: 'private-granted', requiredExposure: 'private-required'
|
||||
} }, 403),
|
||||
log: line => logs.push(line)
|
||||
}), /org_context_http_403/);
|
||||
const diagnostic = JSON.parse(logs[0]);
|
||||
assert.equal(diagnostic.upstreamCode, 'unrecognized');
|
||||
assert.equal(diagnostic.grantedExposure, undefined);
|
||||
assert.equal(diagnostic.requiredExposure, undefined);
|
||||
assert.ok(!logs[0].includes('private-'));
|
||||
});
|
||||
|
||||
test('HTML proxy challenges are distinguishable without logging HTML or bypassing the failure', async () => {
|
||||
const logs = [];
|
||||
await assert.rejects(verifyOrganization(settings(), {
|
||||
fetchImpl: async () => new Response('<html>private-challenge-body</html>', {
|
||||
status: 403, headers: { 'content-type': 'text/html', 'cf-mitigated': 'challenge' }
|
||||
}),
|
||||
log: line => logs.push(line)
|
||||
}), /org_context_http_403/);
|
||||
const diagnostic = JSON.parse(logs[0]);
|
||||
assert.equal(diagnostic.responseKind, 'html');
|
||||
assert.equal(diagnostic.proxyChallenge, true);
|
||||
assert.equal(diagnostic.upstreamCode, 'unrecognized');
|
||||
assert.ok(!logs[0].includes('private-challenge-body'));
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { stagingSettings } from '../scripts/staging-settings.mjs';
|
||||
|
||||
const fixture = () => ({
|
||||
AUTH_CLIENT_ID: 'staging-client', AUTH_ISSUER: 'https://identity.staging.example/oidc/',
|
||||
ABC_API_BASE_URL: 'https://feedback.staging.example/',
|
||||
ABC_PROJECT_ID: '01900000-0000-7000-8000-000000000001',
|
||||
ABC_CHANNEL_ID: '01900000-0000-7000-8000-000000000002',
|
||||
ORG_CONTEXT_ENABLED: 'false',
|
||||
PROD_AUTH_CLIENT_ID: 'production-client', PROD_ABC_API_BASE_URL: 'https://production.example'
|
||||
});
|
||||
|
||||
test('staging uses its mapped API and SSO settings independently of production values', () => {
|
||||
const settings = stagingSettings(fixture());
|
||||
assert.equal(settings.vars.AUTH_CLIENT_ID, 'staging-client');
|
||||
assert.equal(settings.vars.AUTH_AUTHORIZE_URL, 'https://identity.staging.example/oidc/oauth2/auth');
|
||||
assert.equal(settings.vars.AUTH_TOKEN_URL, 'https://identity.staging.example/oidc/oauth2/token');
|
||||
assert.equal(settings.vars.AUTH_USERINFO_URL, 'https://identity.staging.example/oidc/userinfo');
|
||||
assert.equal(settings.vars.ABC_API_BASE_URL, 'https://feedback.staging.example');
|
||||
assert.equal(settings.vars.PROD_AUTH_CLIENT_ID, undefined);
|
||||
});
|
||||
|
||||
test('missing staging settings cannot silently fall back to a different environment', () => {
|
||||
for (const name of ['AUTH_CLIENT_ID', 'AUTH_ISSUER', 'ABC_API_BASE_URL', 'ABC_PROJECT_ID', 'ABC_CHANNEL_ID', 'ORG_CONTEXT_ENABLED']) {
|
||||
const env = fixture();
|
||||
delete env[name];
|
||||
assert.throws(() => stagingSettings(env), new RegExp('STG_' + name));
|
||||
}
|
||||
assert.throws(() => stagingSettings({ ...fixture(), ABC_CHANNEL_ID: 'not-a-uuid' }), /STG_ABC_CHANNEL_ID/);
|
||||
assert.throws(() => stagingSettings({ ...fixture(), AUTH_ISSUER: 'http://identity.example' }), /STG_AUTH_ISSUER/);
|
||||
assert.throws(() => stagingSettings({ ...fixture(), ABC_API_BASE_URL: 'https://api.example/api' }), /STG_ABC_API_BASE_URL/);
|
||||
});
|
||||
|
||||
test('staging organization credentials remain separate from public Worker variables', () => {
|
||||
const settings = stagingSettings({ ...fixture(), ORG_CONTEXT_ENABLED: 'true',
|
||||
ORG_CONTEXT_TENANT_IDS: '["038326b6-954a-48a7-a85f-efd83f62b82a"]',
|
||||
ORG_CONTEXT_KEY_ID: 'fixture-key', ORG_CONTEXT_KEY_SECRET: 'fixture-secret'
|
||||
});
|
||||
assert.equal(settings.secrets.ORG_CONTEXT_KEY_SECRET, 'fixture-secret');
|
||||
assert.equal(settings.vars.ORG_CONTEXT_KEY_SECRET, undefined);
|
||||
});
|
||||
|
||||
test('staging validation never deploys and dry-run pins the staging Worker and config', () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'qa-staging-test-'));
|
||||
const capture = join(directory, 'commands.jsonl');
|
||||
writeFileSync(join(directory, 'npx'), `#!/usr/bin/env node
|
||||
require('node:fs').appendFileSync(process.env.QA_TEST_CAPTURE, JSON.stringify(process.argv.slice(2)) + '\\n');
|
||||
`, { mode: 0o700 });
|
||||
const script = fileURLToPath(new URL('../scripts/deploy-worker.mjs', import.meta.url));
|
||||
const env = { ...process.env, ...fixture(), PATH: directory + ':' + process.env.PATH, QA_TEST_CAPTURE: capture };
|
||||
try {
|
||||
const validation = spawnSync(process.execPath, [script, '--validate-only'], { env, encoding: 'utf8' });
|
||||
assert.equal(validation.status, 0, validation.stderr);
|
||||
assert.equal(existsSync(capture), false);
|
||||
const dryRun = spawnSync(process.execPath, [script, '--dry-run'], { env, encoding: 'utf8' });
|
||||
assert.equal(dryRun.status, 0, dryRun.stderr);
|
||||
const commands = readFileSync(capture, 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(commands.length, 1);
|
||||
const args = commands[0];
|
||||
assert.ok(args.includes('--dry-run'));
|
||||
assert.ok(args.includes('baron-qa-gateway-test'));
|
||||
assert.ok(args.includes('wrangler.toml'));
|
||||
assert.ok(args.includes('ABC_API_BASE_URL:https://feedback.staging.example'));
|
||||
assert.ok(!args.some(value => value.includes('production.example') || value === 'secret'));
|
||||
} finally { rmSync(directory, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import worker from '../src/index.js';
|
||||
|
||||
const destination = 'https://test.baroncs.co.kr/ko/sw-inquiry.html';
|
||||
|
||||
for (const configFile of ['wrangler.toml', 'wrangler.production.toml']) {
|
||||
test(configFile + ' logout expires its own session and redirects to the inquiry homepage', async () => {
|
||||
const config = readFileSync(new URL('../' + configFile, import.meta.url), 'utf8');
|
||||
const setting = name => config.match(new RegExp('^' + name + ' = "([^"]+)"', 'm'))[1];
|
||||
const env = {
|
||||
SESSION_COOKIE_NAME: setting('SESSION_COOKIE_NAME'),
|
||||
AUTH_POST_LOGOUT_REDIRECT_URI: setting('AUTH_POST_LOGOUT_REDIRECT_URI')
|
||||
};
|
||||
const response = await worker.fetch(new Request('https://qa.example.test/auth/logout?return_url=https://untrusted.example/&redirect_uri=https://untrusted.example/', {
|
||||
headers: { cookie: env.SESSION_COOKIE_NAME + '=existing-session' }
|
||||
}), env);
|
||||
assert.equal(response.status, 302);
|
||||
assert.equal(response.headers.get('location'), destination);
|
||||
const cookie = response.headers.get('set-cookie');
|
||||
assert.ok(cookie.startsWith(env.SESSION_COOKIE_NAME + '=;'));
|
||||
for (const attribute of ['Max-Age=0', 'Path=/', 'Secure', 'HttpOnly', 'SameSite=Lax', 'Expires=Thu, 01 Jan 1970 00:00:00 GMT']) assert.ok(cookie.includes(attribute));
|
||||
assert.match(response.headers.get('cache-control'), /no-store/);
|
||||
});
|
||||
}
|
||||
|
||||
for (const [label, value, expected] of [
|
||||
['no configured destination', undefined, '/'],
|
||||
['same-origin relative destination', '/signed-out', '/signed-out'],
|
||||
['invalid URL', 'https://[invalid', '/'],
|
||||
['script URL', 'javascript:alert(1)', '/'],
|
||||
['external insecure URL', 'http://untrusted.example/', '/'],
|
||||
['URL containing credentials', 'https://user:pass@untrusted.example/', '/']
|
||||
]) {
|
||||
test('logout handles ' + label + ' while clearing the session', async () => {
|
||||
const response = await worker.fetch(new Request('https://qa.example.test/auth/logout'), {
|
||||
SESSION_COOKIE_NAME: 'fixture_session', AUTH_POST_LOGOUT_REDIRECT_URI: value
|
||||
});
|
||||
assert.equal(response.status, 302);
|
||||
assert.equal(response.headers.get('location'), 'https://qa.example.test' + expected);
|
||||
assert.match(response.headers.get('set-cookie'), /fixture_session=; Max-Age=0/);
|
||||
});
|
||||
}
|
||||
@@ -10,11 +10,14 @@ preview_urls = false
|
||||
|
||||
[vars]
|
||||
AUTH_REDIRECT_URI = "https://qa.baroncs.co.kr/auth/callback"
|
||||
AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa.baroncs.co.kr/"
|
||||
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"
|
||||
|
||||
+4
-4
@@ -2,15 +2,15 @@ name = "baron-qa-gateway-test"
|
||||
main = "src/index.js"
|
||||
compatibility_date = "2026-09-18"
|
||||
account_id = "81fa2d48964d31dd0da9558f9ce601d1"
|
||||
workers_dev = false
|
||||
preview_urls = false
|
||||
|
||||
routes = [
|
||||
{ pattern = "qa-test.baroncs.co.kr", custom_domain = true }
|
||||
]
|
||||
# qa-test.baroncs.co.kr is already attached in the Cloudflare dashboard.
|
||||
# Omit route/routes to preserve that connection without zone routing API calls.
|
||||
|
||||
[vars]
|
||||
AUTH_REDIRECT_URI = "https://qa-test.baroncs.co.kr/auth/callback"
|
||||
AUTH_POST_LOGOUT_REDIRECT_URI = "https://qa-test.baroncs.co.kr/"
|
||||
AUTH_POST_LOGOUT_REDIRECT_URI = "https://test.baroncs.co.kr/ko/sw-inquiry.html"
|
||||
AUTH_SCOPE = "openid tenants profile email"
|
||||
AUTH_CLIENT_ID = "e16898a7-acd2-420a-a224-cdcd89ff66cd"
|
||||
AUTH_AUTHORIZE_URL = "https://sso.hmac.kr/oidc/oauth2/auth"
|
||||
|
||||
Reference in new issue
Block a user