7fce5a789c
guard пропускает GetClientSideComponents (без него страница не грузит SPFx), probe с --local-build запускает Chromium без проверок Local Network Access, которые режут загрузку https://localhost:4321 с SharePoint. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
119 lines
6.3 KiB
JavaScript
119 lines
6.3 KiB
JavaScript
// Общий модуль для работы с реальным тенантом под учёткой пользователя.
|
||
// ЛЮБОЙ Playwright-сценарий в режиме --tenant обязан создавать контекст через newTenantContext().
|
||
//
|
||
// Что делает:
|
||
// - пускает браузер только на разрешённый сайт (+ служебные хосты Microsoft);
|
||
// - отменяет запросы, меняющие данные (создание/изменение/удаление) в SharePoint и Graph;
|
||
// - пропускает чтение, включая чтение через POST по белому списку;
|
||
// - пишет журнал всех заблокированных и разрешённых не-GET запросов;
|
||
// - по желанию записывает HAR с реальными ответами (для превращения бага в тест).
|
||
|
||
const READ_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||
|
||
// SharePoint/Graph эндпоинты, которые читают данные, хотя используют POST
|
||
const READ_VIA_POST = [
|
||
/\/_api\/contextinfo/i,
|
||
/\/RenderListDataAsStream/i,
|
||
/\/GetItems\b/i,
|
||
/\/_api\/search\/postquery/i,
|
||
/\/GetUpdatedFormDigest/i,
|
||
/\/_api\/web\/GetClientSideComponents/i, // манифесты веб-партов, без них страница не грузит SPFx
|
||
/\/_api\/SP\.Utilities\.Utility\.ResolvePrincipalInCurrentContext/i,
|
||
/\/_api\/SP\.UI\.ApplicationPages\.ClientPeoplePickerWebServiceInterface\.clientPeoplePickerSearchUser/i,
|
||
/\/_api\/sp\.userprofiles\.peoplemanager/i,
|
||
/graph\.microsoft\.com\/.*\/\$batch/i, // проверяется отдельно по телу
|
||
/graph\.microsoft\.com\/.*\/search\/query/i,
|
||
/graph\.microsoft\.com\/.*\/getByIds/i,
|
||
];
|
||
|
||
const DATA_HOST = /(\.sharepoint\.com|graph\.microsoft\.com)$/i;
|
||
const EXTRA_TEST_HOST = process.env.GUARD_TEST_HOST; // только для самопроверки скрипта
|
||
|
||
// Дополнительные API проекта, где запись тоже надо блокировать (например, api.tsnu.kz).
|
||
// Задаются через opts.dataHosts или переменную окружения QA_DATA_HOSTS="api.tsnu.kz,other.host"
|
||
let extraDataHosts = (process.env.QA_DATA_HOSTS || '').split(',').map(h => h.trim().toLowerCase()).filter(Boolean);
|
||
|
||
function isDataHost(host) {
|
||
host = host.toLowerCase();
|
||
return DATA_HOST.test(host) || extraDataHosts.includes(host) || (EXTRA_TEST_HOST && host === EXTRA_TEST_HOST);
|
||
}
|
||
|
||
function batchIsReadOnly(body) {
|
||
if (!body) return false;
|
||
try {
|
||
const json = JSON.parse(body); // Graph JSON batch
|
||
return Array.isArray(json.requests) && json.requests.every(r => READ_METHODS.has(String(r.method).toUpperCase()));
|
||
} catch {
|
||
// SharePoint OData $batch (multipart): ищем методы внутри частей
|
||
const methods = [...body.matchAll(/^(GET|POST|PUT|PATCH|MERGE|DELETE)\s+https?:\/\//gim)].map(m => m[1].toUpperCase());
|
||
return methods.length > 0 && methods.every(m => m === 'GET');
|
||
}
|
||
}
|
||
|
||
export function classify(request) {
|
||
const url = new URL(request.url());
|
||
const method = request.method().toUpperCase();
|
||
const override = (request.headers()['x-http-method'] || '').toUpperCase();
|
||
|
||
if (!isDataHost(url.hostname)) return { action: 'allow', reason: 'not-data-host' };
|
||
if (READ_METHODS.has(method) && !override) return { action: 'allow', reason: 'read' };
|
||
if (['MERGE', 'DELETE', 'PUT', 'PATCH'].includes(override)) return { action: 'block', reason: `x-http-method ${override}` };
|
||
|
||
if (/\/\$batch/i.test(url.pathname)) {
|
||
return batchIsReadOnly(request.postData())
|
||
? { action: 'allow', reason: 'read-only batch' }
|
||
: { action: 'block', reason: 'batch with writes or unparsed' };
|
||
}
|
||
if (READ_VIA_POST.some(re => re.test(url.href))) return { action: 'allow', reason: 'read via POST (allowlist)' };
|
||
return { action: 'block', reason: `${method} to data endpoint` };
|
||
}
|
||
|
||
/**
|
||
* @param browser Playwright Browser
|
||
* @param opts { storageState, siteUrl, dataHosts?, harPath?, localBuild?, viewport?, isMobile?, deviceScaleFactor? }
|
||
* @returns { context, log } log.blocked / log.allowedWrites / log.offsite
|
||
*/
|
||
export async function newTenantContext(browser, opts) {
|
||
if (!opts.storageState) throw new Error('storageState обязателен');
|
||
if (!opts.siteUrl) throw new Error('siteUrl обязателен: адрес сайта, на котором разрешено тестирование');
|
||
|
||
if (opts.dataHosts) extraDataHosts = [...new Set([...extraDataHosts, ...opts.dataHosts.map(h => h.toLowerCase())])];
|
||
const site = new URL(opts.siteUrl);
|
||
const log = { blocked: [], allowedNonGet: [], offsite: [] };
|
||
|
||
const context = await browser.newContext({
|
||
storageState: opts.storageState,
|
||
viewport: opts.viewport,
|
||
isMobile: opts.isMobile,
|
||
deviceScaleFactor: opts.deviceScaleFactor,
|
||
// Нужно только для локальной сборки SPFx (https://localhost:4321 с dev-сертификатом)
|
||
ignoreHTTPSErrors: Boolean(opts.localBuild),
|
||
...(opts.harPath ? { recordHar: { path: opts.harPath, content: 'embed', urlFilter: url => /\/_api\/|graph\.microsoft\.com/.test(url) || isDataHost(new URL(url).hostname) } } : {}),
|
||
});
|
||
|
||
await context.route('**/*', async route => {
|
||
const req = route.request();
|
||
const url = new URL(req.url());
|
||
|
||
// Навигация документа на другой SharePoint-сайт — не пускаем
|
||
if (req.isNavigationRequest() && req.frame() === req.frame().page()?.mainFrame()
|
||
&& /\.sharepoint\.com$/i.test(url.hostname)
|
||
&& !(url.hostname === site.hostname && url.pathname.toLowerCase().startsWith(site.pathname.toLowerCase()))) {
|
||
log.offsite.push(url.href.slice(0, 200));
|
||
return route.abort('blockedbyclient');
|
||
}
|
||
|
||
const c = classify(req);
|
||
if (c.action === 'block') {
|
||
log.blocked.push({ method: req.method(), url: url.href.slice(0, 200), reason: c.reason });
|
||
return route.abort('blockedbyclient');
|
||
}
|
||
if (!READ_METHODS.has(req.method().toUpperCase()) && isDataHost(url.hostname)) {
|
||
log.allowedNonGet.push({ method: req.method(), url: url.href.slice(0, 200), reason: c.reason });
|
||
}
|
||
return route.continue();
|
||
});
|
||
|
||
return { context, log };
|
||
}
|