257 lines
14 KiB
JavaScript
257 lines
14 KiB
JavaScript
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('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]];
|
|
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, 'manual');
|
|
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.orgContextError, 'org_context_request_failed');
|
|
assert.equal(result.company, user.company);
|
|
assert.deepEqual(logs, [['org_context_request_failed']]);
|
|
} finally { console.error = saved; }
|
|
});
|
|
|
|
test('error diagnostics reject arbitrary upstream messages even when prefixed with org_context', async () => {
|
|
const original = console.error;
|
|
const logs = [];
|
|
console.error = (...args) => logs.push(args);
|
|
try {
|
|
const result = await enrichUserOrganization(user, env(), { fetchImpl: async () => {
|
|
throw new Error('org_context_private_header_fixture_secret');
|
|
} });
|
|
assert.equal(result.orgContextStatus, 'unavailable');
|
|
assert.equal(result.orgContextError, 'org_context_unavailable');
|
|
assert.deepEqual(logs, [['org_context_unavailable']]);
|
|
assert.ok(!JSON.stringify(result).includes('fixture_secret'));
|
|
} finally { console.error = original; }
|
|
});
|
|
|
|
test('successful organization resolution clears previous failure diagnostics', async () => {
|
|
const result = await enrichUserOrganization({ ...user, orgContextStatus: 'unavailable', orgContextError: 'org_context_timeout' }, env(), { fetchImpl: async () => response(payload()) });
|
|
assert.equal(result.orgContextStatus, 'resolved');
|
|
assert.equal(result.company, '실제 회사');
|
|
assert.equal(result.department, '설계부');
|
|
assert.equal(result.orgContextError, undefined);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
|
|
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);
|
|
});
|