236 lines
13 KiB
JavaScript
236 lines
13 KiB
JavaScript
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: 'COMPANY', members: [{ id: USER, isPrimary: true, grade: '확인된 직급', position: '확인된 직책' }] }
|
|
] }
|
|
] } };
|
|
}
|
|
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_position: 'forged position', requester_grade: 'forged grade', 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_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');
|
|
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');
|
|
assert.equal(body.user.orgContextError, 'org_context_http_503');
|
|
});
|
|
} finally { console.error = original; }
|
|
});
|
|
test('session exposes only a safe configuration error when runtime organization credentials are missing', async () => {
|
|
const env = configuration({ ORG_CONTEXT_KEY_SECRET: '' });
|
|
const original = console.error;
|
|
console.error = () => {};
|
|
try {
|
|
await mockedFetch(() => assert.fail('configuration error must precede network access'), 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(response.status, 200);
|
|
assert.equal(body.authenticated, true);
|
|
assert.equal(body.user.requesterId, USER);
|
|
assert.equal(body.user.orgContextStatus, 'unavailable');
|
|
assert.equal(body.user.orgContextError, 'org_context_missing_credentials');
|
|
assert.ok(!JSON.stringify(body).includes('integration-key'));
|
|
assert.ok(!JSON.stringify(body).includes('signed-session-fixture'));
|
|
});
|
|
} 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);
|
|
});
|
|
|
|
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' });
|
|
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 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);
|
|
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, 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();
|
|
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);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
}
|