chore: Playwright и axe для браузерного QA веб-парта
Зависимости и навык spfx-qa для проверки портала в браузере (сценарии, снимки, проверка доступности). Каталоги сессии и отчётов (.auth/, .qa-reports/) — в .gitignore. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// Общий модуль для работы с реальным тенантом под учёткой пользователя.
|
||||
// ЛЮБОЙ 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\/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 };
|
||||
}
|
||||
Reference in New Issue
Block a user