This commit is contained in:
1 parent
e4843e7d1d
commit
1808f14cc6
17 files changed
+711
-23
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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
@@ -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&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&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
@@ -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
@@ -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&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&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
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 : {} };
|
||||
}
|
||||
@@ -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
@@ -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'
|
||||
};
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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, '서버 부서');
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
Reference in new issue
Block a user