fix: SSO 로그인 사용자 정보 연동 개선
Deploy EG-BIM QA Gateway / deploy (push) Successful in 42s

This commit is contained in:
root committed 2026-10-27 17:02:12 +09:00
1 parent e4843e7d1d
commit 1808f14cc6
17 files changed
+711 -23

No files matched your search

+13 -2
View File
@@ -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
+37 -1
View File
@@ -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 번들에 넣지 않습니다.
+6 -10
View File
@@ -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;
+1 -1
View File
@@ -4,5 +4,5 @@
<body>
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell" id="detail"><div class="section-heading"><div><h2>문의 상세</h2><p>문의 내용과 답변을 확인할 수 있습니다.</p></div></div><article class="detail-card"><div class="detail-head"><div class="detail-title-line"><h2><span class="category" data-detail-category>문의</span> <span data-detail-title>문의 제목</span></h2><span class="status review" data-detail-status>문의접수</span></div><div class="detail-meta"><span data-detail-date>2026-09-17</span><span data-detail-author>작성자</span><span data-detail-company>회사</span><span data-detail-department>부서</span><span data-detail-secret hidden>🔒 비밀글</span></div></div><div class="detail-body" data-detail-content></div><div class="attachments"><h3>첨부파일</h3><div data-detail-attachments><span class="help-text">첨부된 파일이 없습니다.</span></div></div><div id="completionNotice" class="completion-notice" hidden><div><strong>처리 완료 안내</strong><p>담당자가 처리를 완료했습니다. 내용을 확인하셨다면 완료 확인을 눌러주세요.</p></div><button class="button button-primary" id="completionButton" type="button">완료 확인</button></div><div id="completionDoneNotice" class="completion-done-notice" hidden>완료 처리 된 요청 건입니다.</div><div class="detail-actions"><div class="owner-actions"><button class="button button-outline" id="editButton" type="button" hidden>수정</button><button class="button button-danger" id="deleteButton" type="button" hidden>삭제</button></div><a class="button button-outline" href="index.html">목록</a></div></article><form id="editForm" class="edit-form" hidden><h3>문의 수정</h3><div class="form-row"><label class="form-label" for="editCategory">구분</label><div class="form-control"><select id="editCategory" class="select"><option value="오류문의">오류문의</option><option value="개선문의">개선문의</option><option value="일반문의">일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="editTitle">제목</label><div class="form-control"><input id="editTitle" class="input" maxlength="200" required></div></div><div class="form-row"><label class="form-label" for="editContent">내용</label><div class="form-control column"><textarea id="editContent" class="textarea" maxlength="10000" required></textarea></div></div><div class="form-row"><div class="form-label">설정</div><div class="form-control"><label class="check"><input id="editSecret" type="checkbox"> 비밀글</label></div></div><div class="form-footer"><button class="button button-outline" id="editCancel" type="button">취소</button><button class="button button-primary" type="submit">수정 저장</button></div><div id="editError" class="form-error" role="alert" hidden></div></form><section class="comment-box"><h3>답변 <span id="commentCount">0</span></h3><div id="comments"></div><form id="commentForm" class="comment-form"><label class="sr-only" for="commentContent">댓글 내용</label><textarea id="commentContent" class="textarea" maxlength="5000" placeholder="답변 또는 추가 문의를 입력해주세요" required></textarea><div class="comment-attachment-input"><label class="button button-primary comment-image-button" for="commentImages">이미지 첨부</label><input id="commentImages" type="file" accept="image/*" multiple><div id="commentImagePreview" class="comment-image-preview"></div></div><div class="comment-form-actions"><span id="commentError" class="form-error" role="alert" hidden></span><button class="button button-primary" id="commentSubmit" type="submit">댓글 등록</button></div></form></section><div class="form-footer"><button class="button button-outline" id="backButton" type="button">목록으로</button></div></section>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260929-2"></script>
</main><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
</body></html>
+1 -1
View File
@@ -23,6 +23,6 @@
</section>
</main>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20260929-2"></script>
<script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
</body>
</html>
+1 -1
View File
@@ -4,5 +4,5 @@
<body>
<main class="egbim-page"><section class="page-hero"><div class="hero-inner"><span class="hero-kicker">EG-BIM SUPPORT</span><h1>Q&amp;A</h1><p>EG-BIM 관련 문의하기</p></div><nav class="sub-nav" aria-label="EG-BIM 지원 메뉴"><div class="sub-nav-inner"><a href="index.html" aria-current="page">문의하기(Q&amp;A)</a><a href="#" aria-disabled="true" class="disabled-tab">원격지원</a></div></nav></section>
<section class="page-shell"><div class="section-heading"><div><h2>문의 등록</h2><p>로그인한 사용자 정보로 문의가 등록됩니다.</p></div></div><div class="form-notice" data-auth-state>로그인 상태를 확인하고 있습니다.</div><div class="form-error" id="formError" role="alert"></div><form id="qaForm" novalidate><div class="form-card" id="writeFields"><div class="form-row"><label class="form-label" for="category">구분 <span class="required">*</span></label><div class="form-control"><select class="select" id="category" required><option value="">선택해주세요</option><option>오류문의</option><option>개선문의</option><option>일반문의</option></select></div></div><div class="form-row"><label class="form-label" for="title">제목 <span class="required">*</span></label><div class="form-control"><input class="input" id="title" type="text" maxlength="100" placeholder="문의 제목을 입력해주세요" required></div></div><div class="form-row"><span class="form-label">공개 설정</span><div class="form-control"><label class="check"><input id="secret" type="checkbox"> 비밀글로 등록</label></div></div><div class="form-row"><label class="form-label" for="content">내용 <span class="required">*</span></label><div class="form-control column"><textarea class="textarea" id="content" placeholder="문의 내용을 작성해주세요" required></textarea><span class="help-text">개인정보, 비밀번호, 인증번호 등 민감한 정보는 입력하지 마세요.</span></div></div><div class="form-row"><label class="form-label" for="attachment">첨부파일</label><div class="form-control column"><input class="file-input" id="attachment" type="file" multiple><span class="help-text">파일은 문의와 함께 안전하게 업로드됩니다. 파일당 최대 30MB</span></div></div></div><div class="form-footer"><a class="button button-outline" href="index.html">취소</a><button class="button button-primary" id="submitButton" type="submit">문의 등록</button></div></form></section>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20260929-2"></script>
</main><div id="toast" class="toast" role="status" aria-live="polite"></div><script src="config.js?v=20260921-3"></script><script src="app.js?v=20261027-org-1"></script>
</body></html>
+3 -1
View File
@@ -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"
+3 -1
View File
@@ -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"
}
+38
View File
@@ -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;
}
+19
View File
@@ -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 : {} };
}
+19
View File
@@ -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;
}
+13 -5
View File
@@ -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'
};
+190
View File
@@ -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' };
}
}
+162
View File
@@ -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/);
});
+30
View File
@@ -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, '서버 부서');
});
+174
View File
@@ -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);
});
});
+1
View File
@@ -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"