Files
baron_qa_write/tests/feedback-privacy.test.js
T
root a5a5f24f92
Deploy EG-BIM QA Gateway / deploy (push) Successful in 50s
fix: enforce private feedback access across list and detail
2026-11-13 10:28:00 +09:00

133 lines
6.9 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { createHmac } from 'node:crypto';
import worker from '../src/index.js';
const POST = '01900000-0000-7000-8000-000000000001';
const COMMENT = '01900000-0000-7000-8000-000000000002';
const OWNER = '9951229e-eea5-4376-96f4-78989bc3a506';
const OTHER = 'ee2f39ac-fe52-4cfb-b4e3-4ae1d114c916';
const env = { SESSION_COOKIE_NAME: 'fixture', SESSION_SECRET: 'test-session-key', ABC_API_KEY: 'fixture-api-key', ABC_PROJECT_ID: 'project', ABC_CHANNEL_ID: 'channel' };
const feedback = (extra = {}) => ({ id: POST, requester_id: OWNER, is_secret: true,
title: 'PRIVATE_TITLE', contents: 'PRIVATE_CONTENT', requester_email: 'PRIVATE_EMAIL',
attachments: [{ url: 'https://example.test/PRIVATE_ATTACHMENT' }],
comments: [{ content: 'PRIVATE_COMMENT' }], ...extra });
const response = (body, status = 200) => Response.json(body, { status });
function request(path = '', method = 'GET', viewer = OTHER, body) {
const user = { requesterId: viewer, userUuid: OWNER, ssoSubject: OWNER, requesterTenantId: 'shared-tenant', name: 'same-name', email: 'PRIVATE_EMAIL', roles: ['ADMIN', 'SUPPORT'] };
const payload = Buffer.from(JSON.stringify({ user, expiresAt: Date.now() + 60000 })).toString('base64url');
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
return new Request('https://qa.example.test/api/feedbacks' + path, { method,
headers: { cookie: `${env.SESSION_COOKIE_NAME}=${payload}.${signature}`, 'content-type': 'application/json', 'x-requester-id': OWNER },
body: body === undefined ? undefined : JSON.stringify(body) });
}
async function upstream(handler, run) {
const original = globalThis.fetch;
const calls = [];
globalThis.fetch = async (url, init) => { calls.push({ url: String(url), ...init }); return handler(url, init); };
try { await run(calls); } finally { globalThis.fetch = original; }
}
test('list masks private data before sending it to a different signed-in user and keeps pagination', async () => {
await upstream(() => response({ items: [feedback(), feedback({ id: COMMENT, is_secret: false })], meta: { total: 102, totalPages: 2, page: 1 } }), async calls => {
const result = await worker.fetch(request(), env);
const { items, meta } = await result.json();
assert.equal(result.headers.get('cache-control'), 'no-store');
assert.equal(items[0].title, '비밀글입니다.');
assert.equal(items[0].access_restricted, true);
assert.doesNotMatch(JSON.stringify(items[0]), /PRIVATE_/);
assert.equal(items[1].contents, 'PRIVATE_CONTENT');
assert.deepEqual(meta, { total: 102, totalPages: 2, page: 1 });
assert.equal(calls[0].headers['x-requester-id'], OTHER);
});
});
for (const flag of [true, 'true', 1, '1', 'TRUE', 'yes']) {
for (const wrap of [value => value, value => ({ data: value }), value => ({ feedback: value })]) {
test(`private detail refuses non-owner even when upstream returns 200 (${String(flag)}, ${wrap.toString()})`, async () => {
await upstream(() => response(wrap(feedback({ is_secret: flag }))), async () => {
const result = await worker.fetch(request('/' + POST), env);
assert.equal(result.status, 403);
assert.doesNotMatch(await result.text(), /PRIVATE_/);
});
});
}
}
for (const fields of [
{ fields: { is_secret: 'true', requester_id: OWNER } },
{ extra_fields: { is_secret: true, requester_id: OWNER } },
{ metadata: { isSecret: true, requesterId: OWNER } },
{ fields: [{ key: 'is_secret', value: 'true' }, { key: 'requester_id', value: OWNER }] },
{ fields: [{ key: 'is_secret', value: 'false' }, { key: 'is_secret', value: 'true' }] },
{ is_secret: false, metadata: { is_secret: true } },
{ is_secret: 'unexpected-value' }
]) {
test(`nested and conflicting privacy metadata is protected: ${JSON.stringify(fields)}`, async () => {
const record = { id: POST, title: 'PRIVATE_TITLE', contents: 'PRIVATE_CONTENT', ...fields };
await upstream(() => response(record), async () => {
const result = await worker.fetch(request('/' + POST), env);
assert.equal(result.status, 403);
assert.doesNotMatch(await result.text(), /PRIVATE_/);
});
});
}
test('private records with missing author ID are denied; the owner still receives their own record', async () => {
for (const ownerId of ['', OWNER.toUpperCase()]) {
await upstream(() => response(feedback({ requester_id: ownerId })), async () => {
const result = await worker.fetch(request('/' + POST, 'GET', OWNER), env);
assert.equal(result.status, ownerId ? 200 : 403);
if (ownerId) assert.equal((await result.json()).feedback.contents, 'PRIVATE_CONTENT');
});
}
});
for (const [path, method, body] of [
['', 'PATCH', { title: 'attempt' }], ['', 'DELETE'],
['/comments', 'GET'], ['/comments', 'POST', { content: 'attempt' }],
['/comments/' + COMMENT, 'PATCH', { content: 'attempt' }], ['/comments/' + COMMENT, 'DELETE'],
['/completion-confirmation', 'POST']
]) {
test(`private parent blocks direct ${method} ${path || 'detail'} before comments or mutations reach upstream`, async () => {
await upstream(() => response(feedback()), async calls => {
const result = await worker.fetch(request('/' + POST + path, method, OTHER, body), env);
assert.equal(result.status, 403);
assert.doesNotMatch(await result.text(), /PRIVATE_/);
assert.equal(calls.length, 1);
assert.ok(calls[0].url.endsWith('/feedbacks/' + POST));
assert.equal(calls[0].method, 'GET');
assert.equal(calls[0].headers['x-requester-id'], OTHER);
});
});
}
test('comment access fails closed when the parent cannot be loaded', async () => {
for (const status of [403, 404, 503]) {
await upstream(() => response({ message: 'PRIVATE_ERROR' }, status), async calls => {
const result = await worker.fetch(request('/' + POST + '/comments'), env);
assert.equal(result.status, status === 503 ? 502 : status);
assert.doesNotMatch(await result.text(), /PRIVATE_/);
assert.equal(calls.length, 1);
});
}
});
test('admin-key search cannot reveal private words via matching locked records', async () => {
await upstream(() => assert.fail('private content must not be queried through the admin search API'), async () => {
const result = await worker.fetch(request('?searchText=PRIVATE_CONTENT&fieldKey=contents'), env);
assert.equal(result.status, 400);
});
});
for (const isSecret of [false, true]) {
test(`${isSecret ? 'own private' : 'public'} feedback still permits reading comments`, async () => {
await upstream((url) => response(String(url).includes('/comments') ? { comments: [{ content: 'allowed' }] } : feedback({ is_secret: isSecret })), async calls => {
const result = await worker.fetch(request('/' + POST + '/comments', 'GET', isSecret ? OWNER : OTHER), env);
assert.equal(result.status, 200);
assert.equal((await result.json()).comments[0].content, 'allowed');
assert.equal(calls.length, 2);
});
});
}