Files
Тимур Городецкий 7fce5a789c chore: доработки скриптов браузерного QA
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>
2026-10-15 12:31:59 +05:00

119 lines
6.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Общий модуль для работы с реальным тенантом под учёткой пользователя.
// ЛЮБОЙ 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 };
}