diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index aa9304e..556f676 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -13,6 +13,14 @@ jobs: 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 }} steps: - name: Checkout @@ -35,7 +43,10 @@ jobs: run: npm install --no-fund --no-audit - name: Check source - run: npm run check + run: npm run check && npm test + + - name: Verify organization integration settings + run: npm run org:verify - name: Register Worker session secret shell: bash @@ -53,4 +64,4 @@ jobs: run: npm run r2:upload - name: Deploy Worker - run: npx wrangler deploy --name baron-qa-gateway-test + run: node scripts/deploy-worker.mjs diff --git a/README.md b/README.md index 1f1a327..b0e2488 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Worker는 OAuth Authorization Code + PKCE를 사용하고, callback에서 검증 ## Gitea Actions 등록값 -저장소 Settings → Actions → Secrets에 아래 2개를 등록합니다. +저장소 Settings → Actions → Secrets에 아래 기본 Secret 3개를 등록합니다. | 이름 | 종류 | 값 | |---|---|---| @@ -92,3 +92,39 @@ Cloudflare API Token에는 최소한 다음 권한이 필요합니다. `.gitea/workflows/deploy.yml`은 `main` push 또는 수동 실행 시 세션 secret을 Worker에 등록하고 R2 업로드 후 `baron-qa-gateway-test`를 배포합니다. 실제 secret 값은 로그에 출력하지 않습니다. 현재 화면은 API 설정 전에도 QA 흐름을 확인할 수 있도록 샘플 글과 로컬 테스트 저장을 포함합니다. 운영 배포 시에는 Gitea Secret `ABC_API_KEY`가 Worker에 등록되어야 하며, 작성페이지는 Worker 프록시를 통해 ABC API를 호출합니다. + + +## 회사·부서 조직 연동 (스테이징) + +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) | + +조회는 `SCOPED`, `identifiers`를 사용합니다. 기준 테넌트는 UUID JSON 배열로 등록하며 UUID v4를 허용합니다. 각 테넌트를 최대 3개씩 동시 조회하고, 완전한 응답만 캐시합니다. 조회 타임아웃은 요청당 5초이며 일부 테넌트 실패·범위 불일치·구성원 누락은 검증 실패로 처리합니다. + +기본 `ORG_CONTEXT_ENABLED=false`에서는 조직 API를 호출하지 않습니다. `true`로 배포하면 `npm run org:verify`가 실제 키로 조직 API를 먼저 검증하며, 실패 시 새 Worker 배포를 진행하지 않습니다. 성공 시에는 조직·사용자·소속 건수만 로그에 출력합니다. 전체 응답·전화번호·키 원문은 출력하지 않습니다. + +Worker는 서명된 세션의 사용자 UUID로 소속을 찾습니다. 조회 조직 컨텍스트·대표 소속을 선택하고, 기존 requester ID·tenant ID·권한은 유지합니다. 새 문의는 `requester_affiliation`에 회사, `requester_department`에 부서를 저장합니다. JSON과 첨부 multipart에 같은 값을 사용합니다. 목록·상세는 글에 저장된 정보를 사용하며 조회 시점의 조직으로 과거 글을 바꾸지 않습니다. + +조직 조회가 실패하면 기존 세션·글쓰기 흐름과 확인된 SSO 정보를 유지합니다. 누락되거나 모호한 사용자는 `orgContextStatus`로 구분하고 다른 작성자의 정보를 대신 쓰지 않습니다. + +로컬 검증: + +```sh +npm run check +npm test +WRANGLER_LOG_PATH=/tmp/qa-worker-build.log node scripts/deploy-worker.mjs --dry-run +``` + +마지막 명령은 Secret 등록이나 배포를 실행하지 않고 번들만 확인합니다. 실제 키를 로컬로 준비했다면 런타임 환경변수 이름으로 주입한 뒤 `npm run org:verify`를 실행할 수 있습니다. 키 값을 커맨드라인 인자나 소스에 넣지 않습니다. + +기존 마이그레이션 글의 보정은 `baron_qa/scripts/qa_org_metadata.py` 도구에서 별도로 수행합니다. 개인정보가 포함된 엑셀·보정 보고서를 이 저장소의 정적 파일이나 Worker 번들에 넣지 않습니다. diff --git a/egbim/app.js b/egbim/app.js index 918d312..29b2c42 100644 --- a/egbim/app.js +++ b/egbim/app.js @@ -118,7 +118,7 @@ requesterTenantId: value(["requester_tenant_id", "requesterTenantId", "tenant_id", "tenantId"]), requesterEmail: requesterEmail, category: normalizeCategory(value(["Category", "category"])), - company: getCompanyNameFromEmail(requesterEmail) || value(["requester_company", "company", "organization", "tenant_name", "tenantName", "requester_affiliation"]), + company: value(["requester_affiliation", "requester_company", "company", "organization", "tenant_name", "tenantName"]) || getCompanyNameFromEmail(requesterEmail), department: value(["requester_department", "department", "team"]) || "-", author: value(["requester_name", "author_name", "authorName", "name", "author"]) || "작성자", title: value(["title", "subject"]) || "제목 없음", @@ -271,11 +271,11 @@ function getPostCompanyLabel(post, user) { post = post || {}; const raw = post.raw || {}; + const savedCompany = raw.requester_affiliation || (raw.extra_fields || {}).requester_affiliation; + if (savedCompany) return savedCompany; + if (post.company && post.company !== "외부 사용자") return post.company; const emailCompany = getCompanyNameFromEmail(post.requesterEmail || raw.requester_email || raw.requesterEmail); if (emailCompany) return emailCompany; - if (post.company && post.company !== "외부 사용자") return post.company; - const company = user && (user.company || user.tenantName); - if (company && post.requesterTenantId && user.tenantId && String(post.requesterTenantId) === String(user.tenantId)) return company; return post.company || "외부 사용자"; } @@ -305,7 +305,7 @@ const userUuid = raw.userUuid || raw.userId || claims.userId || claims.user_id || sessionStorage.getItem('descopeUserId') || ''; const loginId = raw.loginId || (raw.loginIds || [])[0] || raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || readCookie('descope_login_id') || ''; const email = raw.email || claims.email || claimProfile.email || sessionStorage.getItem('loginId') || ''; - const company = getCompanyNameFromEmail(email) || custom.company || raw.company || claims.company || sessionStorage.getItem('company') || ''; + const company = raw.company || custom.company || claims.company || sessionStorage.getItem('company') || getCompanyNameFromEmail(email) || ''; return { userUuid: userUuid, ssoSubject: ssoSubject, @@ -322,7 +322,7 @@ tenantName: tenantName, company: company, familyCompany: custom.familyCompany || custom.family_company || raw.familyCompany || sessionStorage.getItem('familyCompany') || '', - department: custom.team || custom.department || raw.department || claims.department || sessionStorage.getItem('team') || '', + department: raw.department || custom.team || custom.department || claims.department || sessionStorage.getItem('team') || '', rawClaims: claims }; } @@ -351,10 +351,6 @@ async function loadAuthUser() { const localUser = getAuthUser(); - if (localUser && localUser.tenantId) { - resolvedAuthUser = localUser; - return localUser; - } if (!config.ssoSessionEndpoint) { resolvedAuthUser = localUser; return localUser; diff --git a/egbim/detail.html b/egbim/detail.html index 0e0afa8..d5e24f9 100644 --- a/egbim/detail.html +++ b/egbim/detail.html @@ -4,5 +4,5 @@
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

문의 상세

문의 내용과 답변을 확인할 수 있습니다.

문의 문의 제목

문의접수
2026-09-17작성자회사부서

첨부파일

첨부된 파일이 없습니다.
목록

답변 0

-
+ diff --git a/egbim/index.html b/egbim/index.html index 5616658..1f8f07d 100644 --- a/egbim/index.html +++ b/egbim/index.html @@ -23,6 +23,6 @@
- + diff --git a/egbim/write.html b/egbim/write.html index cca7031..ebe6841 100644 --- a/egbim/write.html +++ b/egbim/write.html @@ -4,5 +4,5 @@
EG-BIM SUPPORT

Q&A

EG-BIM 관련 문의하기

문의 등록

로그인한 사용자 정보로 문의가 등록됩니다.

로그인 상태를 확인하고 있습니다.
공개 설정
개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.
파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB
-
+
diff --git a/package.json b/package.json index efa7d39..9b47fd4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "scripts": { "deploy": "wrangler deploy", "r2:upload": "bash scripts/upload-r2.sh", - "check": "node --check src/index.js && node --check egbim/app.js" + "check": "node --check src/index.js && node --check src/org-context.js && node --check egbim/app.js", + "test": "node --test --test-isolation=none tests/*.test.js", + "org:verify": "node scripts/verify-org-context.mjs" }, "devDependencies": { "wrangler": "4.134.0" diff --git a/qa-secrets.json.example b/qa-secrets.json.example index 760dafb..272c99c 100644 --- a/qa-secrets.json.example +++ b/qa-secrets.json.example @@ -1,4 +1,6 @@ { "SESSION_SECRET": "replace-with-openssl-rand-hex-32-output", - "ABC_API_KEY": "replace-with-project-scoped-abc-api-key" + "ABC_API_KEY": "replace-with-project-scoped-abc-api-key", + "ORG_CONTEXT_KEY_ID": "replace-with-integration-key-id", + "ORG_CONTEXT_KEY_SECRET": "replace-with-integration-key-secret" } diff --git a/scripts/deploy-worker.mjs b/scripts/deploy-worker.mjs new file mode 100644 index 0000000..66f57c8 --- /dev/null +++ b/scripts/deploy-worker.mjs @@ -0,0 +1,38 @@ +import { spawn } from 'node:child_process'; +import { deploymentOrgSettings } from './org-context-settings.mjs'; + +const worker = 'baron-qa-gateway-test'; +const dryRun = process.argv.includes('--dry-run'); +function execute(args, input) { + return new Promise((resolve, reject) => { + const child = spawn('npx', ['wrangler', ...args], { + stdio: [input === undefined ? 'ignore' : 'pipe', 'inherit', 'inherit'] + }); + child.once('error', (error) => { + console.error('Worker command OS error:', error.code); + reject(new Error('Worker deployment command failed')); + }); + child.once('exit', (code, signal) => { + if (code === 0) resolve(); + else { + console.error('Worker command exit:', signal || code); + reject(new Error('Worker deployment command failed')); + } + }); + if (input !== undefined) { + child.stdin.once('error', () => reject(new Error('Worker secret input failed'))); + child.stdin.end(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.'); + process.exitCode = 1; +} diff --git a/scripts/org-context-settings.mjs b/scripts/org-context-settings.mjs new file mode 100644 index 0000000..9ce53d2 --- /dev/null +++ b/scripts/org-context-settings.mjs @@ -0,0 +1,19 @@ +import { orgContextConfig } from '../src/org-context.js'; + +export function deploymentOrgSettings(env) { + const vars = { + ORG_CONTEXT_ENABLED: env.ORG_CONTEXT_ENABLED || 'false', + ORG_CONTEXT_API_URL: env.ORG_CONTEXT_API_URL || 'https://sadmin.hmac.kr/api/v1/integrations/org-context', + ORG_CONTEXT_SCOPE_MODE: env.ORG_CONTEXT_SCOPE_MODE || 'SCOPED', + ORG_CONTEXT_EXPOSURE: env.ORG_CONTEXT_EXPOSURE || 'identifiers', + ORG_CONTEXT_TENANT_IDS: env.ORG_CONTEXT_TENANT_IDS || '[]', + ORG_CONTEXT_CACHE_TTL_SECONDS: env.ORG_CONTEXT_CACHE_TTL_SECONDS || '300' + }; + if (!['true', 'false'].includes(vars.ORG_CONTEXT_ENABLED)) throw new Error('ORG_CONTEXT_ENABLED must be true or false'); + const secrets = { + ORG_CONTEXT_KEY_ID: env.ORG_CONTEXT_KEY_ID || '', + ORG_CONTEXT_KEY_SECRET: env.ORG_CONTEXT_KEY_SECRET || '' + }; + if (vars.ORG_CONTEXT_ENABLED === 'true') orgContextConfig({ ...vars, ...secrets }); + return { vars, secrets: secrets.ORG_CONTEXT_KEY_ID && secrets.ORG_CONTEXT_KEY_SECRET ? secrets : {} }; +} diff --git a/scripts/verify-org-context.mjs b/scripts/verify-org-context.mjs new file mode 100644 index 0000000..30558b6 --- /dev/null +++ b/scripts/verify-org-context.mjs @@ -0,0 +1,19 @@ +import { getOrgDirectory } from '../src/org-context.js'; +import { deploymentOrgSettings } from './org-context-settings.mjs'; + +try { + const { vars, secrets } = deploymentOrgSettings(process.env); + if (vars.ORG_CONTEXT_ENABLED !== 'true') { + console.log('Organization integration disabled; existing behavior retained.'); + } else { + const directory = await getOrgDirectory({ ...vars, ...secrets }); + console.log(JSON.stringify({ + status: 'verified', rootCount: JSON.parse(vars.ORG_CONTEXT_TENANT_IDS).length, + tenantCount: directory.tenantCount, userCount: directory.byUser.size, + membershipCount: directory.membershipCount + })); + } +} catch (error) { + console.error(/^org_context_[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'Organization integration configuration or verification failed.'); + process.exitCode = 1; +} diff --git a/src/index.js b/src/index.js index e936e49..1cab6bf 100644 --- a/src/index.js +++ b/src/index.js @@ -1,3 +1,5 @@ +import { enrichUserOrganization } from './org-context.js'; + const encoder = new TextEncoder(); const decoder = new TextDecoder(); @@ -223,7 +225,8 @@ function normalizeClaims(claims) { const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {}; const tenantName = claims.tenant_name || claims.tenantName || custom.tenant_name || custom.tenantName || (typeof primaryTenant === 'object' ? (primaryTenant.name || primaryTenant.tenantName || primaryTenant.displayName || primaryTenant.label || '') : ''); const email = claims.email || profile.email || ''; - const company = companyNameFromEmail(email) || custom.company || claims.company || claims.organization || profile.company || ''; + const explicitCompany = custom.company || claims.company || claims.organization || profile.company || claims.affiliation || ''; + const company = explicitCompany || companyNameFromEmail(email); const subject = claims.sub || claims.subject || claims.userId || claims.user_id || ''; return { userUuid: claims.userId || claims.user_id || subject, @@ -240,8 +243,9 @@ function normalizeClaims(claims) { phone: claims.phone || claims.phone_number || claims.phoneNumber || claims.mobile || claims.mobilePhone || customPhone || profilePhone, tenantName, company, + companySource: explicitCompany ? 'sso' : 'email', familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '', - department: custom.team || custom.department || claims.department || primaryTenant.name || '', + department: custom.team || custom.department || claims.department || profile.department || (!['COMPANY', 'COMPANY_GROUP', 'ROOT'].includes(String(primaryTenant.type || primaryTenant.orgUnitType || '').toUpperCase()) ? primaryTenant.name : '') || '', position: custom.position || claims.position || profile.position || primaryTenant.position || primaryTenant.title || '', grade: custom.grade || claims.grade || profile.grade || primaryTenant.grade || '' }; @@ -323,14 +327,14 @@ async function getSession(request, env) { } async function sessionResponse(request, env) { - const user = await getSession(request, env); + const user = await enrichUserOrganization(await getSession(request, env), env); return json({ authenticated: Boolean(user), user }, 200, { 'access-control-allow-credentials': 'true' }); } async function createFeedback(request, env) { if (request.method !== 'POST') return json({ error: 'method_not_allowed' }, 405, { allow: 'POST' }); - const user = await getSession(request, env); - if (!user) return json({ error: 'unauthenticated' }, 401); + const sessionUser = await getSession(request, env); + if (!sessionUser) return json({ error: 'unauthenticated' }, 401); if (!env.ABC_API_KEY) return json({ error: 'feedback_api_not_configured' }, 503); if (!env.ABC_PROJECT_ID || !env.ABC_CHANNEL_ID) return json({ error: 'feedback_target_not_configured' }, 503); @@ -362,6 +366,7 @@ async function createFeedback(request, env) { if (!title || !contents || !category) return json({ error: 'title_contents_category_required' }, 400); if (!['ERROR_QNA', 'IMPROVEMENT_QNA', 'GENERAL_QNA'].includes(category)) return json({ error: 'invalid_category' }, 400); + const user = await enrichUserOrganization(sessionUser, env); const feedbackId = isUuidv7(input.feedbackId) ? input.feedbackId : uuidv7(); const requesterId = String(user.requesterId || user.ssoSubject || user.userUuid || ''); const requesterTenantId = String(user.requesterTenantId || user.tenantId || ''); @@ -380,6 +385,9 @@ async function createFeedback(request, env) { requester_email: String(user.email || ''), requester_name: String(user.name || ''), requester_department: String(user.department || ''), + requester_affiliation: String(user.companySource === 'email' ? '' : (user.company || user.familyCompany || '')), + requester_position: String(user.position || ''), + requester_grade: String(user.grade || ''), requester_phone_number: requesterPhone, is_secret: input.is_secret === true || input.is_secret === 'true' || input.is_secret === 1 || input.is_secret === '1' ? 'true' : 'false' }; diff --git a/src/org-context.js b/src/org-context.js new file mode 100644 index 0000000..dbbb15a --- /dev/null +++ b/src/org-context.js @@ -0,0 +1,190 @@ +// Server-only organization lookup. Never return the directory or credentials to a browser. +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const cache = new Map(); +const pending = new Map(); +const text = (value) => typeof value === 'string' ? value.trim() : ''; +const truthy = (value) => value === true || value === 1 || value === '1' || value === 'true'; +const kind = (node) => text(node.orgUnitType || node.type).toUpperCase(); +const isCompany = (node) => kind(node) === 'COMPANY' || text(node.type).toUpperCase() === 'COMPANY'; +const isDepartment = (node) => ['DEPARTMENT', 'DIVISION', 'TEAM', 'ORG_UNIT', 'BUSINESS_UNIT'].includes(kind(node)) || text(node.type).toUpperCase() === 'DEPARTMENT'; + +export function orgContextConfig(env) { + if (!truthy(env.ORG_CONTEXT_ENABLED)) return null; + const endpoint = new URL(env.ORG_CONTEXT_API_URL || 'https://sadmin.hmac.kr/api/v1/integrations/org-context'); + if (endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.search || endpoint.hash) { + throw new Error('org_context_invalid_endpoint'); + } + if ((env.ORG_CONTEXT_SCOPE_MODE || 'SCOPED') !== 'SCOPED') throw new Error('org_context_invalid_mode'); + if ((env.ORG_CONTEXT_EXPOSURE || 'identifiers') !== 'identifiers') throw new Error('org_context_invalid_exposure'); + let roots; + try { roots = JSON.parse(env.ORG_CONTEXT_TENANT_IDS || '[]'); } catch { throw new Error('org_context_invalid_tenants'); } + if (!Array.isArray(roots) || !roots.length || roots.length > 50 || roots.some((id) => typeof id !== 'string' || !UUID.test(id))) { + throw new Error('org_context_invalid_tenants'); + } + roots = [...new Set(roots.map((id) => id.toLowerCase()))].sort(); + const keyId = text(env.ORG_CONTEXT_KEY_ID); + const keySecret = text(env.ORG_CONTEXT_KEY_SECRET); + if (!keyId || !keySecret) throw new Error('org_context_missing_credentials'); + const ttl = Number(env.ORG_CONTEXT_CACHE_TTL_SECONDS || 300); + if (!Number.isFinite(ttl) || ttl < 1 || ttl > 3600) throw new Error('org_context_invalid_ttl'); + return { endpoint: endpoint.toString(), roots, keyId, keySecret, ttl: ttl * 1000 }; +} + +export function buildOrgDirectory(payloads) { + const tenants = new Map(); + const memberships = new Map(); + function visit(node, parentId) { + if (!node || typeof node !== 'object' || !text(node.id)) throw new Error('org_context_invalid_tenant'); + const id = text(node.id).toLowerCase(); + const parent = text(node.parentId || parentId).toLowerCase(); + const existing = tenants.get(id); + const normalized = { ...node, id, parentId: parent, members: undefined, children: undefined }; + if (existing && (text(existing.name) !== text(node.name) || (existing.parentId && parent && existing.parentId !== parent))) { + throw new Error('org_context_conflicting_tenant'); + } + tenants.set(id, { ...existing, ...normalized, parentId: parent || existing?.parentId || '' }); + if (node.members !== undefined && !Array.isArray(node.members)) throw new Error('org_context_invalid_members'); + for (const member of node.members || []) { + if (!member || !UUID.test(text(member.id))) throw new Error('org_context_invalid_member'); + const userId = text(member.id).toLowerCase(); + const key = userId + ':' + id; + const previous = memberships.get(key); + // Conflicting copies must not silently select the last root's version. + if (previous && ['isPrimary', 'department', 'phone'].some((field) => + previous.member[field] !== undefined && member[field] !== undefined && String(previous.member[field]) !== String(member[field]))) { + throw new Error('org_context_conflicting_membership'); + } + memberships.set(key, { tenantId: id, member: { ...previous?.member, ...member, id: userId } }); + } + if (node.children !== undefined && !Array.isArray(node.children)) throw new Error('org_context_invalid_children'); + for (const child of node.children || []) visit(child, id); + } + for (const payload of payloads) { + if (!payload || typeof payload !== 'object' || (!payload.tree && !Array.isArray(payload.tenants))) { + throw new Error('org_context_invalid_response'); + } + if (payload.tree) visit(payload.tree, ''); + for (const node of payload.tenants || []) visit(node, ''); + } + const byUser = new Map(); + for (const appointment of memberships.values()) { + const chain = []; + const seen = new Set(); + let node = tenants.get(appointment.tenantId); + while (node) { + if (seen.has(node.id)) throw new Error('org_context_cycle'); + seen.add(node.id); + chain.unshift(node); + node = tenants.get(node.parentId); + } + const company = [...chain].reverse().find(isCompany); + const department = [...chain].reverse().find(isDepartment); + const departmentName = text(department?.name) || text(appointment.member.department); + const value = { + userId: appointment.member.id, + tenantId: appointment.tenantId, + ancestorIds: chain.map((item) => item.id), + companyTenantId: company?.id || '', + company: text(company?.name), + departmentTenantId: department?.id || '', + department: departmentName, + departmentPath: chain.filter(isDepartment).map((item) => text(item.name)).filter(Boolean).join(' / '), + isPrimary: truthy(appointment.member.isPrimary), + phone: text(appointment.member.phone), + position: text(appointment.member.position || appointment.member.jobTitle), + grade: text(appointment.member.grade) + }; + const list = byUser.get(value.userId) || []; + list.push(value); + byUser.set(value.userId, list); + } + return { byUser, tenantCount: tenants.size, membershipCount: memberships.size }; +} + +export function resolveUserOrganization(directory, user) { + const identities = [...new Set([user.userUuid, user.ssoSubject, user.requesterId].map(text).filter(Boolean).map((id) => id.toLowerCase()))]; + const matched = identities.filter((id) => directory.byUser.has(id)); + if (matched.length !== 1) return { status: matched.length ? 'ambiguous_identity' : 'not_found' }; + const appointments = directory.byUser.get(matched[0]); + const tenantId = text(user.requesterTenantId || user.tenantId).toLowerCase(); + const exact = appointments.filter((item) => item.tenantId === tenantId); + const contextual = exact.length ? exact : appointments.filter((item) => item.ancestorIds.includes(tenantId)); + const candidates = contextual.length ? contextual : appointments; + const primary = candidates.filter((item) => item.isPrimary); + const selected = candidates.length === 1 ? candidates[0] : primary.length === 1 ? primary[0] : null; + return selected ? { status: 'resolved', ...selected } : { status: 'ambiguous_membership' }; +} + +export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now } = {}) { + const config = orgContextConfig(env); + if (!config) return null; + // Include credential rotation without storing the secret in cache keys or diagnostic output. + const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(config.keySecret))); + const fingerprint = Array.from(digest, (byte) => byte.toString(16).padStart(2, '0')).join(''); + const cacheKey = JSON.stringify([config.endpoint, config.roots, config.keyId, fingerprint, config.ttl]); + const previous = cache.get(cacheKey); + if (previous && previous.expiresAt > now()) return previous.directory; + if (pending.has(cacheKey)) return pending.get(cacheKey); + const promise = (async () => { + const responses = new Array(config.roots.length); + let next = 0; + await Promise.all(Array.from({ length: Math.min(3, config.roots.length) }, async () => { + while (next < config.roots.length) { + const index = next++; + const url = new URL(config.endpoint); + url.searchParams.set('tenantId', config.roots[index]); + url.searchParams.set('exposure', 'identifiers'); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + try { + const response = await fetchImpl(url.toString(), { + headers: { accept: 'application/json', 'X-Baron-Key-ID': config.keyId, 'X-Baron-Key-Secret': config.keySecret }, + signal: controller.signal, + redirect: 'error' + }); + if (!response.ok) throw new Error('org_context_http_' + response.status); + const payload = await response.json(); + if (text(payload?.scope?.tenantId).toLowerCase() !== config.roots[index]) throw new Error('org_context_scope_mismatch'); + responses[index] = payload; + } catch (error) { + if (/^org_context_/.test(error?.message || '')) throw error; + throw new Error('org_context_request_failed'); + } finally { clearTimeout(timeout); } + } + })); + const directory = buildOrgDirectory(responses); + if (directory.byUser.size === 0) throw new Error('org_context_members_missing'); + if (cache.size >= 8) cache.delete(cache.keys().next().value); + cache.set(cacheKey, { directory, expiresAt: now() + config.ttl }); + return directory; + })(); + pending.set(cacheKey, promise); + try { return await promise; } finally { pending.delete(cacheKey); } +} + +export async function enrichUserOrganization(user, env, options) { + if (!user || !truthy(env.ORG_CONTEXT_ENABLED)) return user; + try { + const directory = await getOrgDirectory(env, options); + const organization = resolveUserOrganization(directory, user); + if (organization.status !== 'resolved') return { ...user, orgContextStatus: organization.status }; + // Authentication/ownership identifiers and roles are deliberately preserved. + return { + ...user, + company: organization.company || user.company || '', + companySource: organization.company ? 'org-context' : user.companySource, + department: organization.department || user.department || '', + companyTenantId: organization.companyTenantId, + departmentTenantId: organization.departmentTenantId, + departmentPath: organization.departmentPath, + phone: user.phone || organization.phone || '', + position: organization.position || user.position || '', + grade: organization.grade || user.grade || '', + orgContextStatus: 'resolved' + }; + } catch (error) { + // Do not log an upstream body, request headers, or a directory containing personal data. + console.error(/^org_context_[a-z0-9_]+$/.test(error?.message || '') ? error.message : 'org_context_unavailable'); + return { ...user, orgContextStatus: 'unavailable' }; + } +} diff --git a/tests/org-context.test.js b/tests/org-context.test.js new file mode 100644 index 0000000..784c60a --- /dev/null +++ b/tests/org-context.test.js @@ -0,0 +1,162 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildOrgDirectory, resolveUserOrganization, getOrgDirectory, enrichUserOrganization, orgContextConfig } from '../src/org-context.js'; + +const ROOT = '038326b6-954a-48a7-a85f-efd83f62b82a'; +const COMPANY = '9951229e-eea5-4376-96f4-78989bc3a506'; +const DEPT = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916'; +const OTHER = 'd19c10f0-0224-4bbb-bf3e-ce579c5338ea'; +const USER = '01900000-0000-7000-8000-000000000001'; +let sequence = 0; +function env(extra = {}) { + return { ORG_CONTEXT_ENABLED: 'true', ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT]), ORG_CONTEXT_KEY_ID: 'fixture', ORG_CONTEXT_KEY_SECRET: 'secret-' + ++sequence, ...extra }; +} +function payload(root = ROOT) { + return { scope: { tenantId: root }, tree: { id: root, name: '분류 루트', type: 'ROOT', children: [ + { id: COMPANY, name: '실제 회사', type: 'COMPANY', children: [ + { id: DEPT, name: '설계부', type: 'DEPARTMENT', members: [{ id: USER, isPrimary: true, phone: '+8201000000000' }] } + ] } + ] } }; +} +const user = { userUuid: USER, ssoSubject: USER, requesterId: USER, requesterTenantId: ROOT, tenantId: ROOT, roles: ['GENERAL'], company: 'old', department: 'old' }; +const response = (value, status = 200) => new Response(JSON.stringify(value), { status }); + +test('disabled lookup preserves existing behavior and never fetches', async () => { + const actual = await enrichUserOrganization(user, {}, { fetchImpl() { assert.fail('network disabled'); } }); + assert.equal(actual, user); + assert.equal(orgContextConfig({ ORG_CONTEXT_ENABLED: 'false' }), null); +}); +test('accepts UUID v4 roots, normalizes duplicates and validates config', () => { + const config = orgContextConfig(env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, ROOT.toUpperCase()]) })); + assert.deepEqual(config.roots, [ROOT]); + for (const invalid of ['[]', '"not-array"', '["invalid"]', '{']) { + assert.throws(() => orgContextConfig(env({ ORG_CONTEXT_TENANT_IDS: invalid })), /invalid_tenants/); + } + assert.throws(() => orgContextConfig(env({ ORG_CONTEXT_API_URL: 'http://example.test' })), /invalid_endpoint/); + assert.throws(() => orgContextConfig(env({ ORG_CONTEXT_KEY_SECRET: '' })), /missing_credentials/); +}); +test('walks descendants and resolves the actual company instead of classification root', () => { + const directory = buildOrgDirectory([payload()]); + assert.equal(directory.tenantCount, 3); + const organization = resolveUserOrganization(directory, user); + assert.equal(organization.company, '실제 회사'); + assert.equal(organization.department, '설계부'); + assert.equal(organization.departmentPath, '설계부'); +}); +test('merges overlapping roots and flat tenants without losing memberships', () => { + const data = payload(); + data.tenants = [data.tree.children[0].children[0]]; + const directory = buildOrgDirectory([data, { tree: data.tree.children[0] }]); + assert.equal(directory.membershipCount, 1); + assert.equal(directory.tenantCount, 3); + assert.equal(resolveUserOrganization(directory, user).company, '실제 회사'); +}); +test('preserves multiple appointments and prefers the authenticated tenant context', () => { + const data = payload(); + data.tree.children[0].children.push({ id: OTHER, type: 'DEPARTMENT', name: '겸직부서', members: [{ id: USER, isPrimary: false }] }); + const directory = buildOrgDirectory([data]); + assert.equal(directory.membershipCount, 2); + assert.equal(resolveUserOrganization(directory, { ...user, requesterTenantId: OTHER }).department, '겸직부서'); + assert.equal(resolveUserOrganization(directory, user).department, '설계부'); + for (const dept of data.tree.children[0].children) dept.members[0].isPrimary = false; + assert.equal(resolveUserOrganization(buildOrgDirectory([data]), user).status, 'ambiguous_membership'); +}); +test('never matches a different person using email or copies a viewer organization', () => { + const directory = buildOrgDirectory([payload()]); + assert.equal(resolveUserOrganization(directory, { userUuid: OTHER, email: 'same@example.test' }).status, 'not_found'); +}); +test('does not mistake a company or classification root for a department', () => { + const data = { tree: { id: ROOT, type: 'ROOT', name: '일반회사', members: [{ id: USER }] } }; + const resolved = resolveUserOrganization(buildOrgDirectory([data]), user); + assert.equal(resolved.company, ''); + assert.equal(resolved.department, ''); +}); +test('rejects cycles and conflicting overlapping memberships', () => { + assert.throws(() => buildOrgDirectory([{ tenants: [ + { id: ROOT, parentId: COMPANY, members: [{ id: USER }] }, { id: COMPANY, parentId: ROOT } + ] }]), /cycle/); + const first = payload(), second = payload(); + second.tree.children[0].children[0].members[0].isPrimary = false; + assert.throws(() => buildOrgDirectory([first, second]), /conflicting_membership/); +}); +test('fetches each root with identifiers and caches only complete responses', async () => { + const config = env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, OTHER]) }); + const urls = []; + let currentTime = 1000; + const fetchImpl = async (url, init) => { + const parsed = new URL(url); + urls.push(url); + assert.equal(parsed.searchParams.get('exposure'), 'identifiers'); + assert.equal(init.headers['X-Baron-Key-ID'], config.ORG_CONTEXT_KEY_ID); + assert.equal(init.headers['X-Baron-Key-Secret'], config.ORG_CONTEXT_KEY_SECRET); + assert.equal(init.redirect, 'error'); + return response(payload(parsed.searchParams.get('tenantId'))); + }; + // Use disjoint roots with no repeated tenant parent to emulate two independent subtrees. + const independentFetch = async (url, init) => { + if (new URL(url).searchParams.get('tenantId') === OTHER) { + urls.push(url); + return response({ scope: { tenantId: OTHER }, tree: { id: OTHER, name: 'Other', type: 'ROOT' } }); + } + return fetchImpl(url, init); + }; + const options = { fetchImpl: independentFetch, now: () => currentTime }; + const first = await getOrgDirectory(config, options); + assert.equal(first.tenantCount, 4); + assert.equal(await getOrgDirectory(config, options), first); + assert.equal(urls.length, 2); + currentTime += 300001; + await getOrgDirectory(config, options); + assert.equal(urls.length, 4); +}); +test('concurrent lookups share one in-flight request', async () => { + const config = env(); + let count = 0; + const fetchImpl = async () => { count++; await new Promise((resolve) => setTimeout(resolve, 5)); return response(payload()); }; + await Promise.all([getOrgDirectory(config, { fetchImpl }), getOrgDirectory(config, { fetchImpl })]); + assert.equal(count, 1); +}); +test('rejects wrong scope and never caches a partial or forbidden result', async () => { + const config = env(); + await assert.rejects(getOrgDirectory(config, { fetchImpl: async () => response(payload(OTHER)) }), /scope_mismatch/); + await assert.rejects(getOrgDirectory(config, { fetchImpl: async () => response({ secret: 'not logged' }, 403) }), /http_403/); + assert.equal((await getOrgDirectory(config, { fetchImpl: async () => response(payload()) })).byUser.size, 1); +}); +test('enrichment updates only profile fields and preserves authentication and roles', async () => { + const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => response(payload()) }); + assert.equal(result.company, '실제 회사'); + assert.equal(result.department, '설계부'); + for (const field of ['requesterId', 'requesterTenantId', 'ssoSubject', 'userUuid', 'tenantId', 'roles']) assert.deepEqual(result[field], user[field]); + assert.ok(!JSON.stringify(result).includes('X-Baron')); + assert.ok(!('byUser' in result)); +}); +test('upstream failure preserves existing profile and emits only a safe error code', async () => { + const saved = console.error; + const logs = []; + console.error = (...args) => logs.push(args); + try { + const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => { throw new Error('Secret header: private-contact'); } }); + assert.equal(result.orgContextStatus, 'unavailable'); + assert.equal(result.company, user.company); + assert.deepEqual(logs, [['org_context_request_failed']]); + } finally { console.error = saved; } +}); + +test('a failing root is not accepted as a partial complete directory', async () => { + const config = env({ ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT, OTHER]) }); + await assert.rejects(getOrgDirectory(config, { fetchImpl: async (url) => { + const root = new URL(url).searchParams.get('tenantId'); + return root === OTHER ? response({}, 403) : response(payload()); + } }), /http_403/); + let fetched = 0; + const directory = await getOrgDirectory(config, { fetchImpl: async (url) => { + fetched++; + const root = new URL(url).searchParams.get('tenantId'); + return response(root === ROOT ? payload() : { scope: { tenantId: OTHER }, tree: { id: OTHER, type: 'ROOT', name: 'Other' } }); + } }); + assert.equal(fetched, 2); + assert.equal(directory.byUser.size, 1); +}); +test('identifiers verification rejects a structure-only response with no members', async () => { + await assert.rejects(getOrgDirectory(env(), { fetchImpl: async () => response({ scope: { tenantId: ROOT }, tree: { id: ROOT, name: 'Root' } }) }), /members_missing/); +}); diff --git a/tests/page-organization.test.js b/tests/page-organization.test.js new file mode 100644 index 0000000..0f2ad79 --- /dev/null +++ b/tests/page-organization.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const source = readFileSync(new URL('../egbim/app.js', import.meta.url), 'utf8'); +const context = vm.createContext({ + window: { QA_CONFIG: {}, location: { origin: 'https://qa.example.test' } }, + document: { cookie: '', addEventListener() {} }, + sessionStorage: { getItem() { return null; } }, + localStorage: { getItem() { return null; } }, URL +}); +vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.testPage = { normalizeRemotePost, getPostCompanyLabel, normalizeAuthUser }; })();'), context); +const page = context.testPage; + +test('saved organization has priority over email-domain inference in list and detail', () => { + const post = page.normalizeRemotePost({ id: 'fixture', requester_email: 'fixture@brsw.kr', requester_affiliation: '저장된 회사', requester_department: '저장된 부서' }); + assert.equal(post.company, '저장된 회사'); + assert.equal(post.department, '저장된 부서'); + assert.equal(page.getPostCompanyLabel(post, { company: '현재 조회자 회사' }), '저장된 회사'); +}); +test('a matching tenant does not copy the current viewer company to an unrelated author', () => { + const label = page.getPostCompanyLabel({ requesterTenantId: 'shared', raw: {} }, { tenantId: 'shared', company: '다른 작성자 회사' }); + assert.notEqual(label, '다른 작성자 회사'); +}); +test('server organization has priority over stale browser custom attributes', () => { + const user = page.normalizeAuthUser({ company: '서버 회사', department: '서버 부서', email: 'fixture@brsw.kr', customAttributes: { company: 'old', department: 'old' } }, {}); + assert.equal(user.company, '서버 회사'); + assert.equal(user.department, '서버 부서'); +}); diff --git a/tests/worker-organization.test.js b/tests/worker-organization.test.js new file mode 100644 index 0000000..01e4407 --- /dev/null +++ b/tests/worker-organization.test.js @@ -0,0 +1,174 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createHmac } from 'node:crypto'; +import worker from '../src/index.js'; +import { deploymentOrgSettings } from '../scripts/org-context-settings.mjs'; + +const ROOT = '038326b6-954a-48a7-a85f-efd83f62b82a'; +const COMPANY = '9951229e-eea5-4376-96f4-78989bc3a506'; +const DEPT = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916'; +const USER = '01900000-0000-7000-8000-000000000001'; +const FEEDBACK = '01900000-0000-7000-8000-000000000002'; +const profile = { userUuid: USER, requesterId: USER, ssoSubject: USER, tenantId: ROOT, requesterTenantId: ROOT, phone: '+8201000000000', email: 'fixture@brsw.kr', company: 'old inference', roles: ['GENERAL'] }; +let sequence = 0; +const configuration = (extra = {}) => ({ + SESSION_COOKIE_NAME: 'fixture_session', SESSION_SECRET: 'signed-session-fixture', ABC_API_KEY: 'project-key', + ABC_PROJECT_ID: FEEDBACK, ABC_CHANNEL_ID: FEEDBACK, ORG_CONTEXT_ENABLED: 'true', + ORG_CONTEXT_KEY_ID: 'integration-key', ORG_CONTEXT_KEY_SECRET: 'fixture-' + ++sequence, + ORG_CONTEXT_TENANT_IDS: JSON.stringify([ROOT]), ...extra +}); +function sessionCookie(env) { + const payload = Buffer.from(JSON.stringify({ user: profile, expiresAt: Date.now() + 60000 })).toString('base64url'); + return env.SESSION_COOKIE_NAME + '=' + payload + '.' + createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url'); +} +function organization() { + return { scope: { tenantId: ROOT }, tree: { id: ROOT, name: '분류', type: 'ROOT', children: [ + { id: COMPANY, name: '정확한 회사', type: 'COMPANY', children: [ + { id: DEPT, name: '정확한 부서', type: 'DEPARTMENT', members: [{ id: USER, isPrimary: true }] } + ] } + ] } }; +} +const json = (value, status = 200) => new Response(JSON.stringify(value), { status }); +async function mockedFetch(callback, body) { + const original = globalThis.fetch; + globalThis.fetch = callback; + try { return await body(); } finally { globalThis.fetch = original; } +} + +test('unauthenticated create is rejected without contacting any upstream', async () => { + await mockedFetch(() => assert.fail('unauthenticated network request'), async () => { + const response = await worker.fetch(new Request('https://qa.example.test/api/feedbacks', { method: 'POST' }), configuration()); + assert.equal(response.status, 401); + }); +}); +for (const multipart of [false, true]) { + test(`new ${multipart ? 'multipart' : 'JSON'} feedback stores server-resolved organization and preserves ownership`, async () => { + const env = configuration(); + let saved; + await mockedFetch(async (url, init) => { + if (String(url).includes('/org-context')) return json(organization()); + saved = init; + return json({ id: FEEDBACK }); + }, async () => { + const input = { title: 'fixture', contents: 'body', Category: 'GENERAL_QNA', requester_affiliation: 'forged company', requester_id: 'forged owner' }; + let body, headers = { cookie: sessionCookie(env) }; + if (multipart) { + body = new FormData(); + Object.entries(input).forEach(([key, value]) => body.append(key, value)); + body.append('images', new File(['fixture-image'], 'image.png', { type: 'image/png' })); + } else { body = JSON.stringify(input); headers['content-type'] = 'application/json'; } + const result = await worker.fetch(new Request('https://qa.example.test/api/feedbacks', { method: 'POST', headers, body }), env); + assert.equal(result.status, 200); + const payload = multipart ? Object.fromEntries(saved.body.entries()) : JSON.parse(saved.body); + assert.equal(payload.requester_affiliation, '정확한 회사'); + assert.equal(payload.requester_department, '정확한 부서'); + assert.equal(payload.requester_id, USER); + assert.equal(payload.requester_tenant_id, ROOT); + assert.equal(saved.headers['x-api-key'], 'project-key'); + if (multipart) { + assert.equal(saved.headers['content-type'], undefined); + assert.equal((await payload.images.text()), 'fixture-image'); + } + assert.ok(!JSON.stringify(await result.json()).includes('integration-key')); + }); + }); +} +test('existing signed sessions are enriched without requiring another login or changing session identity', async () => { + const env = configuration(); + await mockedFetch(async () => json(organization()), async () => { + const response = await worker.fetch(new Request('https://qa.example.test/auth/session', { headers: { cookie: sessionCookie(env) } }), env); + const body = await response.json(); + assert.equal(body.authenticated, true); + assert.equal(body.user.company, '정확한 회사'); + assert.equal(body.user.requesterTenantId, ROOT); + assert.deepEqual(body.user.roles, ['GENERAL']); + assert.equal(response.headers.get('set-cookie'), null); + assert.equal(body.user.byUser, undefined); + }); +}); +test('organization service failure does not break an existing authenticated session', async () => { + const env = configuration(); + const original = console.error; + console.error = () => {}; + try { + await mockedFetch(async () => json({}, 503), async () => { + const response = await worker.fetch(new Request('https://qa.example.test/auth/session', { headers: { cookie: sessionCookie(env) } }), env); + assert.equal(response.status, 200); + const body = await response.json(); + assert.equal(body.authenticated, true); + assert.equal(body.user.requesterId, USER); + assert.equal(body.user.orgContextStatus, 'unavailable'); + }); + } finally { console.error = original; } +}); +test('list reads saved historical organization without calling the organization API', async () => { + const env = configuration(); + let calls = 0; + await mockedFetch(async (url) => { + assert.ok(!String(url).includes('org-context')); + calls++; + return json({ items: [{ id: FEEDBACK, requester_affiliation: '작성 당시 회사', requester_department: '작성 당시 부서' }] }); + }, async () => { + const response = await worker.fetch(new Request('https://qa.example.test/api/feedbacks', { headers: { cookie: sessionCookie(env) } }), env); + assert.equal(response.status, 200); + assert.equal((await response.json()).items[0].requester_affiliation, '작성 당시 회사'); + assert.equal(calls, 1); + }); +}); +test('disabled deployment works without organization credentials and enabled deployment requires both', () => { + assert.equal(deploymentOrgSettings({}).vars.ORG_CONTEXT_ENABLED, 'false'); + assert.deepEqual(deploymentOrgSettings({}).secrets, {}); + assert.deepEqual(deploymentOrgSettings({ ORG_CONTEXT_ENABLED: 'false', ORG_CONTEXT_KEY_ID: 'partial-setup' }).secrets, {}); + assert.throws(() => deploymentOrgSettings({ ORG_CONTEXT_ENABLED: 'true' }), /invalid_tenants/); + const result = deploymentOrgSettings(configuration()); + assert.equal(result.vars.ORG_CONTEXT_EXPOSURE, 'identifiers'); + assert.equal(result.vars.ORG_CONTEXT_KEY_SECRET, undefined); + assert.ok(result.secrets.ORG_CONTEXT_KEY_SECRET); +}); + +test('OAuth login still completes and uses explicit SSO company instead of email inference', async () => { + const env = configuration({ ORG_CONTEXT_ENABLED: 'false', OAUTH_COOKIE_NAME: 'fixture_oauth', AUTH_CLIENT_ID: 'fixture-client', + AUTH_AUTHORIZE_URL: 'https://identity.example.test/authorize', AUTH_TOKEN_URL: 'https://identity.example.test/token', + AUTH_USERINFO_URL: 'https://identity.example.test/userinfo', AUTH_REDIRECT_URI: 'https://qa.example.test/auth/callback' }); + const start = await worker.fetch(new Request('https://qa.example.test/auth/login?return_url=%2Fegbim%2Fwrite.html'), env); + assert.equal(start.status, 302); + const authorize = new URL(start.headers.get('location')); + const stateCookie = start.headers.getSetCookie()[0].split(';')[0]; + const claims = { sub: USER, nonce: authorize.searchParams.get('nonce'), tenant_id: ROOT, email: 'fixture@brsw.kr', name: '테스트 사용자', company: 'SSO 회사', tenants: [{ id: ROOT, name: 'SSO 부서', ancestors: [{ id: COMPANY, type: 'COMPANY', name: 'SSO 회사' }] }] }; + const token = Buffer.from('{}').toString('base64url') + '.' + Buffer.from(JSON.stringify(claims)).toString('base64url') + '.fixture'; + await mockedFetch(async (url) => String(url).endsWith('/token') ? json({ id_token: token, access_token: 'fixture-token' }) : json(claims), async () => { + const callback = new URL(env.AUTH_REDIRECT_URI); + callback.searchParams.set('state', authorize.searchParams.get('state')); + callback.searchParams.set('code', 'fixture-code'); + const result = await worker.fetch(new Request(callback, { headers: { cookie: stateCookie } }), env); + assert.equal(result.status, 302); + assert.equal(result.headers.get('location'), 'https://qa.example.test/egbim/write.html'); + const cookie = result.headers.getSetCookie().find((value) => value.startsWith(env.SESSION_COOKIE_NAME + '=')).split(';')[0]; + const session = await worker.fetch(new Request('https://qa.example.test/auth/session', { headers: { cookie } }), env); + const { user } = await session.json(); + assert.equal(user.requesterId, USER); + assert.equal(user.requesterTenantId, ROOT); + assert.equal(user.company, 'SSO 회사'); + assert.equal(user.department, 'SSO 부서'); + }); +}); + +test('feedback update keeps verified owner headers and cannot overwrite organization or ownership from browser input', async () => { + const env = configuration(); + await mockedFetch(async (url, init) => { + assert.ok(!String(url).includes('org-context')); + assert.equal(init.headers['x-requester-id'], USER); + assert.equal(init.headers['x-requester-tenant-id'], ROOT); + const body = JSON.parse(init.body); + assert.equal(body.requester_id, undefined); + assert.equal(body.requester_affiliation, undefined); + assert.equal(body.title, '수정 제목'); + return json({ id: FEEDBACK }); + }, async () => { + const response = await worker.fetch(new Request('https://qa.example.test/api/feedbacks/' + FEEDBACK, { + method: 'PATCH', headers: { cookie: sessionCookie(env), 'content-type': 'application/json' }, + body: JSON.stringify({ title: '수정 제목', requester_id: 'forged', requester_affiliation: 'forged' }) + }), env); + assert.equal(response.status, 200); + }); +}); diff --git a/wrangler.toml b/wrangler.toml index e0f9fa5..5938abd 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -19,6 +19,7 @@ AUTH_USERINFO_URL = "https://sso.hmac.kr/oidc/userinfo" SESSION_COOKIE_NAME = "baron_qa_session_v2" OAUTH_COOKIE_NAME = "baron_qa_oauth_v2" SESSION_TTL_SECONDS = "3600" +ORG_CONTEXT_ENABLED = "false" ABC_API_BASE_URL = "https://feedback.hmac.kr" ABC_PROJECT_ID = "01a0ae3f-fcf6-74b5-bdc4-70d942d6ad72" ABC_CHANNEL_ID = "01a0ae40-51c2-7647-a93c-0249b3759777"