From 922d24cac75375b30314d655e802ae0b3dc09ea5 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 28 Oct 2026 17:00:40 +0900 Subject: [PATCH] =?UTF-8?q?product=20=EB=B0=B0=ED=8F=AC=20pipe=20line=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/index.js | 18 +++++--- src/org-context.js | 20 +++++---- tests/org-context.test.js | 70 +++++++++++++++++++++++++++++++ tests/page-organization.test.js | 16 ++++++- tests/worker-organization.test.js | 52 ++++++++++++++++++++--- 5 files changed, 157 insertions(+), 19 deletions(-) diff --git a/src/index.js b/src/index.js index 1a23462..5566f87 100644 --- a/src/index.js +++ b/src/index.js @@ -209,7 +209,7 @@ function companyNameFromEmail(email) { })[domain] || ''; } -function normalizeClaims(claims) { +function normalizeClaims(claims, env) { const profile = claims.profile && typeof claims.profile === 'object' ? claims.profile : {}; const custom = claims.customAttributes || claims.custom_attributes || {}; const profilePhones = Array.isArray(profile.phones) ? profile.phones : (Array.isArray(claims.phones) ? claims.phones : []); @@ -225,9 +225,15 @@ function normalizeClaims(claims) { const tenantIds = tenantList.map((tenant) => typeof tenant === 'string' ? tenant : (tenant.id || tenant.tenantId || tenant.key || '')).filter(Boolean); if (tenantId && !tenantIds.includes(tenantId)) tenantIds.unshift(tenantId); const primaryTenant = tenantList.find((tenant) => typeof tenant !== 'string' && (tenant.id || tenant.tenantId) === tenantId) || tenantList[0] || {}; + let organizationRoots = []; + try { organizationRoots = JSON.parse(env?.ORG_CONTEXT_TENANT_IDS || '[]'); } catch {} + if (!Array.isArray(organizationRoots)) organizationRoots = []; + const path = [...(Array.isArray(primaryTenant.ancestors) ? primaryTenant.ancestors : []), primaryTenant].filter((node) => node && typeof node === 'object'); + const rootIndex = path.findIndex((node) => organizationRoots.includes(node.id) || ['ROOT', 'COMPANY_GROUP'].includes(String(node.type || '').toUpperCase())); + const companyTenant = rootIndex >= 0 ? path[rootIndex + 1] : path.find((node) => String(node.type || '').toUpperCase() === 'COMPANY'); 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 explicitCompany = custom.company || claims.company || claims.organization || profile.company || claims.affiliation || ''; + const explicitCompany = (rootIndex >= 0 ? companyTenant?.name : '') || custom.requester_affiliation || custom.company || claims.requester_affiliation || claims.company || claims.organization || profile.requester_affiliation || profile.company || claims.affiliation || companyTenant?.name || ''; const company = explicitCompany || companyNameFromEmail(email); const subject = claims.sub || claims.subject || claims.userId || claims.user_id || ''; return { @@ -247,9 +253,9 @@ function normalizeClaims(claims) { company, companySource: explicitCompany ? 'sso' : 'email', familyCompany: custom.familyCompany || custom.family_company || claims.familyCompany || claims.affiliation || '', - 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 || '' + department: custom.requester_department || custom.team || custom.department || claims.requester_department || claims.department || profile.requester_department || profile.department || (primaryTenant !== companyTenant && !organizationRoots.includes(primaryTenant.id) && !['COMPANY_GROUP', 'ROOT'].includes(String(primaryTenant.type || primaryTenant.orgUnitType || '').toUpperCase()) ? primaryTenant.name : '') || '', + position: custom.requester_position || custom.position || claims.requester_position || claims.position || claims.jobTitle || claims.job_title || profile.requester_position || profile.position || profile.jobTitle || primaryTenant.position || primaryTenant.jobTitle || primaryTenant.title || '', + grade: custom.requester_grade || custom.grade || claims.requester_grade || claims.grade || profile.requester_grade || profile.grade || primaryTenant.grade || '' }; } @@ -311,7 +317,7 @@ async function finishLogin(request, env) { const userInfo = await userResponse.json().catch(() => ({})); if (userResponse.ok) claims = { ...claims, ...userInfo }; } - const user = normalizeClaims(claims); + const user = normalizeClaims(claims, env); if (!user.ssoSubject) return oauthError(env, 'sso_subject_missing', '', 502); const sessionPayload = base64url(JSON.stringify({ user, expiresAt: Date.now() + Number(env.SESSION_TTL_SECONDS || 3600) * 1000 })); const session = await signValue(sessionPayload, env.SESSION_SECRET); diff --git a/src/org-context.js b/src/org-context.js index dbbb15a..1ab1f70 100644 --- a/src/org-context.js +++ b/src/org-context.js @@ -6,7 +6,6 @@ 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; @@ -30,9 +29,10 @@ export function orgContextConfig(env) { return { endpoint: endpoint.toString(), roots, keyId, keySecret, ttl: ttl * 1000 }; } -export function buildOrgDirectory(payloads) { +export function buildOrgDirectory(payloads, rootIds = []) { const tenants = new Map(); const memberships = new Map(); + const roots = new Set((rootIds.length ? rootIds : payloads.map((payload) => payload?.scope?.tenantId)).map(text).filter(Boolean).map((id) => id.toLowerCase())); 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(); @@ -77,9 +77,14 @@ export function buildOrgDirectory(payloads) { 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); + // The configured roots are classification/group tenants. Their direct child + // is the real company, even when every descendant has generic COMPANY type. + const rootIndex = chain.findIndex((item) => roots.has(item.id) || ['ROOT', 'COMPANY_GROUP'].includes(text(item.type).toUpperCase())); + const company = rootIndex >= 0 ? chain[rootIndex + 1] : chain.find(isCompany); + const departmentChain = company ? chain.slice(chain.indexOf(company) + 1) : []; + const department = departmentChain.at(-1); + const memberDepartment = text(appointment.member.department); + const departmentName = text(department?.name) || (memberDepartment !== text(company?.name) && !chain.some((item) => roots.has(item.id) && text(item.name) === memberDepartment) ? memberDepartment : ''); const value = { userId: appointment.member.id, tenantId: appointment.tenantId, @@ -88,7 +93,7 @@ export function buildOrgDirectory(payloads) { company: text(company?.name), departmentTenantId: department?.id || '', department: departmentName, - departmentPath: chain.filter(isDepartment).map((item) => text(item.name)).filter(Boolean).join(' / '), + departmentPath: departmentChain.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), @@ -147,12 +152,13 @@ export async function getOrgDirectory(env, { fetchImpl = fetch, now = Date.now } if (text(payload?.scope?.tenantId).toLowerCase() !== config.roots[index]) throw new Error('org_context_scope_mismatch'); responses[index] = payload; } catch (error) { + if (controller.signal.aborted) throw new Error('org_context_timeout'); if (/^org_context_/.test(error?.message || '')) throw error; throw new Error('org_context_request_failed'); } finally { clearTimeout(timeout); } } })); - const directory = buildOrgDirectory(responses); + const directory = buildOrgDirectory(responses, config.roots); 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 }); diff --git a/tests/org-context.test.js b/tests/org-context.test.js index 784c60a..cb662c0 100644 --- a/tests/org-context.test.js +++ b/tests/org-context.test.js @@ -43,6 +43,49 @@ test('walks descendants and resolves the actual company instead of classificatio assert.equal(organization.department, '설계부'); assert.equal(organization.departmentPath, '설계부'); }); + +test('uses the tenant immediately below the classification root as company even if descendants are typed COMPANY', () => { + const data = payload(); + data.tree.type = 'COMPANY_GROUP'; + data.tree.children[0].children[0].type = 'COMPANY'; + const result = resolveUserOrganization(buildOrgDirectory([data]), user); + assert.equal(result.company, '실제 회사'); + assert.equal(result.companyTenantId, COMPANY); + assert.equal(result.department, '설계부'); + assert.equal(result.departmentTenantId, DEPT); + assert.equal(result.departmentPath, '설계부'); +}); + +test('configured roots also resolve company and department when node types are missing', () => { + const data = payload(); + delete data.tree.type; + delete data.tree.children[0].type; + delete data.tree.children[0].children[0].type; + const result = resolveUserOrganization(buildOrgDirectory([data], [ROOT]), user); + assert.equal(result.company, '실제 회사'); + assert.equal(result.department, '설계부'); +}); + +test('mixed node types still use the actual membership leaf as department', () => { + const data = payload(); + const leaf = data.tree.children[0].children[0]; + leaf.type = 'COMPANY'; + data.tree.children[0].children = [{ id: OTHER, type: 'DEPARTMENT', name: '설계본부', children: [leaf] }]; + const result = resolveUserOrganization(buildOrgDirectory([data]), user); + assert.equal(result.company, '실제 회사'); + assert.equal(result.department, '설계부'); + assert.equal(result.departmentPath, '설계본부 / 설계부'); +}); + +test('membership directly on the company never repeats the company name as department', () => { + const data = payload(); + data.tree.children[0].members = [{ id: USER, department: '실제 회사' }]; + delete data.tree.children[0].children; + const result = resolveUserOrganization(buildOrgDirectory([data]), user); + assert.equal(result.company, '실제 회사'); + assert.equal(result.department, ''); + assert.equal(result.departmentTenantId, ''); +}); test('merges overlapping roots and flat tenants without losing memberships', () => { const data = payload(); data.tenants = [data.tree.children[0].children[0]]; @@ -160,3 +203,30 @@ test('a failing root is not accepted as a partial complete directory', async () 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/); }); + +for (const status of [401, 403, 503]) { + test(`organization HTTP ${status} remains distinguishable and is not cached as success`, async () => { + const config = env(); + await assert.rejects(getOrgDirectory(config, { fetchImpl: async () => response({}, status) }), new RegExp('http_' + status)); + assert.equal((await getOrgDirectory(config, { fetchImpl: async () => response(payload()) })).byUser.size, 1); + }); +} + +test('an aborted organization request reports timeout and allows a fresh retry', async (t) => { + const originalTimer = globalThis.setTimeout; + t.mock.method(globalThis, 'setTimeout', (callback, delay) => originalTimer(callback, delay === 5000 ? 5 : delay)); + const config = env(); + await assert.rejects(getOrgDirectory(config, { fetchImpl: async (_url, { signal }) => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true }); + }) }), /org_context_timeout/); + assert.equal((await getOrgDirectory(config, { fetchImpl: async () => response(payload()) })).byUser.size, 1); +}); + +test('rotating integration credentials cannot reuse the previous directory cache', async () => { + const config = env(); + let calls = 0; + const fetchImpl = async () => { calls++; return response(payload()); }; + await getOrgDirectory(config, { fetchImpl }); + await getOrgDirectory({ ...config, ORG_CONTEXT_KEY_SECRET: 'rotated-' + config.ORG_CONTEXT_KEY_SECRET }, { fetchImpl }); + assert.equal(calls, 2); +}); diff --git a/tests/page-organization.test.js b/tests/page-organization.test.js index 0f2ad79..fd9a362 100644 --- a/tests/page-organization.test.js +++ b/tests/page-organization.test.js @@ -10,7 +10,7 @@ const context = vm.createContext({ sessionStorage: { getItem() { return null; } }, localStorage: { getItem() { return null; } }, URL }); -vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.testPage = { normalizeRemotePost, getPostCompanyLabel, normalizeAuthUser }; })();'), context); +vm.runInContext(source.replace(/\}\)\(\);\s*$/, 'globalThis.testPage = { normalizeRemotePost, getPostCompanyLabel, normalizeAuthUser, isPostOwner }; })();'), context); const page = context.testPage; test('saved organization has priority over email-domain inference in list and detail', () => { @@ -28,3 +28,17 @@ test('server organization has priority over stale browser custom attributes', () assert.equal(user.company, '서버 회사'); assert.equal(user.department, '서버 부서'); }); + +test('organization changes affect new author information without changing an existing post snapshot', () => { + const post = page.normalizeRemotePost({ id: 'fixture', requester_affiliation: '작성 당시 회사', requester_department: '작성 당시 부서' }); + const currentUser = page.normalizeAuthUser({ company: '전보 후 회사', department: '전보 후 부서' }, {}); + assert.equal(currentUser.company, '전보 후 회사'); + assert.equal(page.getPostCompanyLabel(post, currentUser), '작성 당시 회사'); + assert.equal(post.department, '작성 당시 부서'); +}); + +test('own-post filtering follows canonical identity even after company and department change', () => { + const user = { requesterId: 'fixture-owner', company: '전보 후 회사', department: '전보 후 부서', name: '동명이인' }; + assert.equal(page.isPostOwner({ requesterId: 'fixture-owner', company: '이전 회사' }, user), true); + assert.equal(page.isPostOwner({ requesterId: 'another-owner', author: '동명이인', company: user.company }, user), false); +}); diff --git a/tests/worker-organization.test.js b/tests/worker-organization.test.js index 01e4407..bb72bc8 100644 --- a/tests/worker-organization.test.js +++ b/tests/worker-organization.test.js @@ -24,7 +24,7 @@ function sessionCookie(env) { 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 }] } + { id: DEPT, name: '정확한 부서', type: 'COMPANY', members: [{ id: USER, isPrimary: true, grade: '확인된 직급', position: '확인된 직책' }] } ] } ] } }; } @@ -50,7 +50,7 @@ for (const multipart of [false, true]) { saved = init; return json({ id: FEEDBACK }); }, async () => { - const input = { title: 'fixture', contents: 'body', Category: 'GENERAL_QNA', requester_affiliation: 'forged company', requester_id: 'forged owner' }; + const input = { title: 'fixture', contents: 'body', Category: 'GENERAL_QNA', requester_affiliation: 'forged company', requester_position: 'forged position', requester_grade: 'forged grade', requester_id: 'forged owner' }; let body, headers = { cookie: sessionCookie(env) }; if (multipart) { body = new FormData(); @@ -62,6 +62,8 @@ for (const multipart of [false, true]) { 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_position, '확인된 직책'); + assert.equal(payload.requester_grade, '확인된 직급'); assert.equal(payload.requester_id, USER); assert.equal(payload.requester_tenant_id, ROOT); assert.equal(saved.headers['x-api-key'], 'project-key'); @@ -126,7 +128,9 @@ test('disabled deployment works without organization credentials and enabled dep assert.ok(result.secrets.ORG_CONTEXT_KEY_SECRET); }); -test('OAuth login still completes and uses explicit SSO company instead of email inference', async () => { +for (const variant of ['explicit SSO company', 'generic tenant hierarchy', 'platform requester fields']) { +const genericHierarchy = variant === 'generic tenant hierarchy'; +test(`OAuth login resolves company with ${variant}`, 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' }); @@ -134,7 +138,18 @@ test('OAuth login still completes and uses explicit SSO company instead of email 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 tenantId = DEPT; + const ancestors = [ + ...(genericHierarchy ? [{ id: ROOT, type: 'COMPANY_GROUP', name: '분류 그룹' }] : []), + { id: COMPANY, type: 'COMPANY', name: 'SSO 회사' }, + ...(genericHierarchy ? [{ id: 'fixture-division', type: 'COMPANY', name: 'SSO 본부' }] : []) + ]; + const claims = { sub: USER, nonce: authorize.searchParams.get('nonce'), tenant_id: tenantId, email: 'fixture@brsw.kr', name: '테스트 사용자', company: genericHierarchy ? '잘못된 부서' : 'SSO 회사', tenants: [{ id: tenantId, ...(genericHierarchy ? { type: 'COMPANY' } : {}), name: 'SSO 부서', ancestors }] }; + if (variant === 'platform requester fields') { + delete claims.company; + delete claims.tenants; + Object.assign(claims, { requester_affiliation: 'SSO 회사', requester_department: 'SSO 부서', requester_grade: 'SSO 직급', requester_position: '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); @@ -147,11 +162,16 @@ test('OAuth login still completes and uses explicit SSO company instead of email 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.requesterTenantId, tenantId); assert.equal(user.company, 'SSO 회사'); assert.equal(user.department, 'SSO 부서'); + if (variant === 'platform requester fields') { + assert.equal(user.grade, 'SSO 직급'); + assert.equal(user.position, 'SSO 직책'); + } }); }); +} test('feedback update keeps verified owner headers and cannot overwrite organization or ownership from browser input', async () => { const env = configuration(); @@ -172,3 +192,25 @@ test('feedback update keeps verified owner headers and cannot overwrite organiza assert.equal(response.status, 200); }); }); + +for (const method of ['GET', 'PATCH', 'DELETE']) { + test(`${method} preserves the API refusal for another author's or private feedback`, 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); + assert.equal(init.method, method); + return json({ message: 'Forbidden resource' }, 403); + }, async () => { + const request = { method, headers: { cookie: sessionCookie(env) } }; + if (method === 'PATCH') { + request.headers['content-type'] = 'application/json'; + request.body = JSON.stringify({ title: '수정 시도' }); + } + const result = await worker.fetch(new Request('https://qa.example.test/api/feedbacks/' + FEEDBACK, request), env); + assert.equal(result.status, 403); + assert.equal((await result.json()).message, 'Forbidden resource'); + }); + }); +}