product 배포 pipe line 연결
Deploy EG-BIM QA Gateway / deploy (push) Successful in 41s

This commit is contained in:
root committed 2026-10-28 17:00:40 +09:00
1 parent 172c3a5a0e
commit 922d24cac7
5 files changed
+157 -19

No files matched your search

+12 -6
View File
@@ -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);
+13 -7
View File
@@ -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 });
+70
View File
@@ -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);
});
+15 -1
View File
@@ -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);
});
+47 -5
View File
@@ -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');
});
});
}