// Общий модуль для работы с реальным тенантом под учёткой пользователя. // ЛЮБОЙ 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 }; }