Files
AWatch-rus/scripts/detmir-portal-tabs-smoke.mjs
T

498 lines
21 KiB
JavaScript

#!/usr/bin/env node
import { createRequire } from "node:module";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const require = createRequire(import.meta.url);
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
function loadPlaywright() {
const home = process.env.HOME || "";
const candidates = [
"playwright",
"playwright-core",
process.env.PLAYWRIGHT_CORE_MODULE,
home ? path.join(home, ".agents/skills/playwright/node_modules/playwright-core") : "",
].filter(Boolean);
for (const candidate of candidates) {
try {
return require(candidate);
} catch {
// Try the next local Playwright installation.
}
}
throw new Error("Playwright module not found");
}
function env(name, fallback = "") {
const value = process.env[name];
return value && value.trim() ? value.trim() : fallback;
}
function authHeaders() {
const explicit = env("DETMIR_PORTAL_SMOKE_AUTH_HEADER");
if (explicit) return { Authorization: explicit };
const basic = env("DETMIR_PORTAL_SMOKE_BASIC_AUTH", env("DETMIR_BASIC_AUTH"));
if (basic) return { Authorization: `Basic ${basic}` };
return {};
}
function extractDataTabs(html) {
return [...html.matchAll(/data-tab=["']([^"']+)["']/g)].map((match) => match[1]);
}
function containsText(text, marker) {
return String(text || "").toLocaleLowerCase("ru-RU").includes(String(marker || "").toLocaleLowerCase("ru-RU"));
}
function securityEventsMode(summary) {
const s = summary || {};
if (!summary || s.backend === "disabled" || s.status === "disabled") return "disabled";
if (s.fallback_used) return "fallback";
return "available";
}
function expectedSecurityEventsText(mode) {
if (mode === "disabled") return "Источник событий безопасности отключён";
if (mode === "fallback") return "События безопасности временно недоступны";
if (mode === "available") return "События безопасности доступны";
return "";
}
function forbiddenExecutiveTerms(text) {
const forbidden = [
"Trust KPI",
"Business Risk",
"Risk Narrative",
"Security Events",
"Incident Candidate",
"Coverage SLA",
"ERROR",
"EMPTY",
"STALE",
"SECURITY_EVENTS_BACKEND",
"CLICKHOUSE_*",
"ClickHouse",
];
return forbidden.filter((term) => containsText(text, term));
}
function assertStaticTabHandlers() {
const index = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/index.html"), "utf8");
const app = fs.readFileSync(path.join(root, "adk-rust/crates/detmir-portal/src/static/app.js"), "utf8");
const tabs = [...new Set(extractDataTabs(index))];
const missing = tabs.filter((tab) => !app.includes(`state.tab === "${tab}"`));
if (missing.length) {
throw new Error(`data-tab without app.js handler: ${missing.join(", ")}`);
}
const loadingTerms = [
"Загрузка данных",
"Данные готовы",
"Данные отсутствуют",
"Данные устарели",
"Ошибка получения данных",
"Получение данных",
"Расчёт показателей",
"Формирование главного вывода",
"Подготовка разделов",
];
const missingLoadingTerms = loadingTerms.filter((term) => !app.includes(term) && !index.includes(term));
if (missingLoadingTerms.length) {
throw new Error(`missing loading/localization terms: ${missingLoadingTerms.join(", ")}`);
}
return tabs;
}
const expectedTabs = [
{ tab: "operator", label: "Обзор", marker: "Представление руководителя" },
{ tab: "employees", label: "Сотрудники", marker: "Карточки сотрудников" },
{ tab: "departments", label: "Подразделения", marker: "Рейтинг подразделений" },
{ tab: "owner", label: "Риски", marker: "Контроль безопасности" },
{ tab: "incidents", label: "Расследования", marker: "Расследования и доказательная база" },
{ tab: "perimeter", label: "Сетевой периметр", marker: "Состояние доступных сигналов" },
{ tab: "reports", label: "Отчеты", marker: "Срезы отчета" },
{ tab: "settings", label: "Настройки", marker: "Параметры расчета" },
];
let smokeStep = "start";
async function main() {
const staticTabs = assertStaticTabHandlers();
const missingExpected = expectedTabs.filter((item) => !staticTabs.includes(item.tab));
if (missingExpected.length) {
throw new Error(`expected tabs absent in index.html: ${missingExpected.map((item) => item.tab).join(", ")}`);
}
const { chromium } = loadPlaywright();
const url = env("DETMIR_PORTAL_SMOKE_URL", "http://127.0.0.1:8720/portal/");
const timeout = Number(env("DETMIR_PORTAL_SMOKE_TIMEOUT_MS", "30000"));
const browser = await chromium.launch({
headless: true,
args: ["--no-sandbox", "--disable-dev-shm-usage"],
});
const context = await browser.newContext({
ignoreHTTPSErrors: true,
viewport: { width: 1440, height: 950 },
extraHTTPHeaders: authHeaders(),
});
const page = await context.newPage();
const errors = [];
const badResponses = [];
page.on("pageerror", (error) => errors.push(error.message));
page.on("console", (message) => {
if (message.type() === "error") errors.push(message.text());
});
page.on("response", (response) => {
if (response.status() >= 400) badResponses.push({ url: response.url(), status: response.status() });
});
const results = [];
const checks = [];
try {
smokeStep = "open_portal";
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
smokeStep = "wait_tabs";
await page.waitForSelector(".tab", { timeout });
smokeStep = "wait_initial_ready";
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY",
null,
{ timeout },
);
checks.push({
name: "loading_refresh_status_ready",
ok:
(await page.locator("#loadingStatus").count()) === 1
&& (await page.locator("#loadingStatus").evaluate((node) => node.dataset.loadStatus)) === "READY"
&& (await page.locator("#loadingStateText").innerText({ timeout })).includes("Данные готовы")
&& (await page.locator("#loadingStageText").innerText({ timeout })).includes("Данные готовы"),
});
smokeStep = "api:security_events_summary";
const reportsPayload = await page.evaluate(async () => {
const response = await fetch("api/reports?role=security", {
cache: "no-store",
headers: { "X-AWatch-Role": "security" },
});
return { ok: response.ok, status: response.status, json: await response.json() };
});
const securitySummary = reportsPayload.json?.security_events_summary || null;
const securityMode = securityEventsMode(securitySummary);
const expectedSecurityMode = env("DETMIR_PORTAL_SMOKE_SECURITY_EVENTS_EXPECT", "auto").toLowerCase();
checks.push({
name: "security_events_api_json",
ok: reportsPayload.ok && Boolean(securitySummary),
mode: securityMode,
status: reportsPayload.status,
});
checks.push({
name: "security_events_expected_mode",
ok: expectedSecurityMode === "auto" || expectedSecurityMode === securityMode,
expected: expectedSecurityMode,
actual: securityMode,
});
for (const item of expectedTabs) {
smokeStep = `tab:${item.tab}`;
await page.click(`button[data-tab="${item.tab}"]`, { timeout });
await page.waitForFunction(
(marker) => document.body && document.body.innerText.includes(marker),
item.marker,
{ timeout },
);
const activeTab = await page.locator(".tab.is-active").innerText({ timeout });
const bodyText = await page.locator("#content").innerText({ timeout });
const stillLoading = bodyText.includes("Загрузка") || bodyText.includes("Обновление периода");
const ok = activeTab.trim() === item.label && bodyText.includes(item.marker) && !stillLoading;
results.push({ tab: item.tab, label: item.label, marker: item.marker, ok });
if (item.tab === "operator") {
const readyBodyText = await page.locator("#content").innerText({ timeout });
const requiredExecutive = [
"Риск-нарратив",
"Достоверность показателей",
"Полнота данных",
"Главный риск",
"Карта рисков",
"Риски подразделений",
"Почему такой индекс активности?",
"Рекомендуемые действия",
"Пробел в данных",
];
checks.push({
name: "executive_dashboard_layer",
ok: requiredExecutive.every((marker) => containsText(readyBodyText, marker)),
required: requiredExecutive,
});
const forbiddenExecutive = forbiddenExecutiveTerms(readyBodyText);
checks.push({
name: "executive_view_no_technical_terms",
ok: forbiddenExecutive.length === 0,
forbidden: forbiddenExecutive,
});
const cardHeadings = await page.$$eval("#content section.card h3, #content h3.section-title", (nodes) =>
nodes.map((node) => node.textContent.trim()),
);
const riskNarrativeIndex = cardHeadings.indexOf("Риск-нарратив");
const executiveIndex = cardHeadings.indexOf("Сводка руководителя");
const businessRiskIndex = cardHeadings.indexOf("Риски подразделений");
const heatmapIndex = cardHeadings.indexOf("Карта рисков");
checks.push({
name: "management_block_order",
ok:
riskNarrativeIndex >= 0 &&
executiveIndex > riskNarrativeIndex &&
businessRiskIndex > executiveIndex &&
heatmapIndex > businessRiskIndex,
order: [
"Риск-нарратив",
"Сводка руководителя",
"Риски подразделений",
"Карта рисков",
],
});
checks.push({
name: "role_view_switcher_present",
ok:
(await page.locator('[data-view-mode="executive"]').count()) === 1
&& (await page.locator('[data-view-mode="manager"]').count()) === 1
&& (await page.locator('[data-view-mode="security"]').count()) === 1
&& (await page.locator('[data-view-mode="forensics"]').count()) === 1
&& (await page.locator('[data-view-mode="admin"]').count()) === 1,
});
const expectedSecurityText = expectedSecurityEventsText(securityMode);
const executiveSecurityTextOk = securityMode === "available"
? containsText(readyBodyText, "События безопасности")
&& containsText(readyBodyText, "Событий за 24 часа")
&& !containsText(readyBodyText, "События безопасности временно недоступны")
&& !containsText(readyBodyText, "Источник событий безопасности отключён")
: containsText(readyBodyText, expectedSecurityText);
checks.push({
name: "security_events_executive_text",
ok:
!expectedSecurityText
|| (executiveSecurityTextOk
&& !containsText(readyBodyText, "SECURITY_EVENTS_BACKEND")
&& !containsText(readyBodyText, "CLICKHOUSE_*")
&& !containsText(readyBodyText, "ClickHouse")),
mode: securityMode,
expected_text: expectedSecurityText,
});
smokeStep = "role:manager";
await page.click('[data-view-mode="manager"]', { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("ТОП-5 лучших подразделений"),
null,
{ timeout },
);
const managerText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "manager_role_view",
ok: [
"Представление менеджера",
"Сводка руководителя",
"ТОП-5 лучших подразделений",
"ТОП-5 проблемных подразделений",
"Heat Map подразделений",
"Markdown-отчет",
].every((marker) => containsText(managerText, marker))
&& !containsText(managerText, "Материалы расследования"),
});
smokeStep = "role:security";
await page.click('[data-view-mode="security"]', { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Материалы расследования"),
null,
{ timeout },
);
const securityText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "security_role_view",
ok: [
"Представление безопасности",
"Требует проверки",
"Расследования",
"Аудит",
"Материалы расследования",
"События безопасности за 24 часа",
"Связь рисков и активности",
].every((marker) => containsText(securityText, marker)),
});
checks.push({
name: "security_role_hides_technical_health_sources",
ok:
!containsText(securityText, "detmir_check")
&& !containsText(securityText, "detmir_status")
&& !containsText(securityText, "command failed")
&& !containsText(securityText, "command returned non-zero"),
});
checks.push({
name: "security_events_security_text",
ok: !expectedSecurityText || containsText(securityText, expectedSecurityText),
mode: securityMode,
expected_text: expectedSecurityText,
});
smokeStep = "role:forensics";
await page.click('[data-view-mode="forensics"]', { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Timeline событий"),
null,
{ timeout },
);
const forensicsText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "forensics_role_view",
ok: [
"Представление расследований",
"Требует проверки",
"Расследования",
"Timeline событий",
"Материалы расследования",
"Аудит",
].every((marker) => containsText(forensicsText, marker))
&& !containsText(forensicsText, "Рейтинг подразделений"),
});
smokeStep = "role:admin";
await page.click('[data-view-mode="admin"]', { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Телеметрия"),
null,
{ timeout },
);
const operationsText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "admin_role_view",
ok: [
"Представление администратора",
"Полнота данных",
"Качество данных",
"События безопасности за 24 часа",
"Качество данных по рабочим местам",
"Ошибки",
"Телеметрия",
].every((marker) => containsText(operationsText, marker)),
});
checks.push({
name: "security_events_operations_text",
ok:
!expectedSecurityText
|| (containsText(operationsText, expectedSecurityText)
&& (securityMode !== "fallback" || containsText(operationsText, "События безопасности временно недоступны"))),
mode: securityMode,
expected_text: expectedSecurityText,
});
smokeStep = "api:role_gates";
const apiUrl = (path) => new URL(path, page.url()).toString();
const [managerSecurity, securityWorkforce, forensicsOk, uebaOk] = await Promise.all([
context.request.get(apiUrl("api/security"), {
headers: { ...authHeaders(), "X-AWatch-Role": "manager" },
}),
context.request.get(apiUrl("api/workforce"), {
headers: { ...authHeaders(), "X-AWatch-Role": "security" },
}),
context.request.get(apiUrl("api/forensics"), {
headers: { ...authHeaders(), "X-AWatch-Role": "forensics" },
}),
context.request.get(apiUrl("api/ueba"), {
headers: { ...authHeaders(), "X-AWatch-Role": "security" },
}),
]);
checks.push({
name: "server_role_gates",
ok:
managerSecurity.status() === 403
&& securityWorkforce.status() === 403
&& forensicsOk.ok()
&& uebaOk.ok(),
statuses: {
manager_security: managerSecurity.status(),
security_workforce: securityWorkforce.status(),
forensics: forensicsOk.status(),
ueba: uebaOk.status(),
},
});
}
if (item.tab === "settings") {
const requiredSettings = [
"Период расчета",
"Рабочий день",
"Порог внимания",
"Порог критического риска",
"Источник правил",
"Дата последнего пересчета",
];
checks.push({
name: "settings_readonly_rows",
ok: requiredSettings.every((marker) => containsText(bodyText, marker)),
required: requiredSettings,
});
}
}
smokeStep = "security:investigation_navigation";
await page.click('button[data-tab="operator"]', { timeout });
await page.click('[data-view-mode="security"]', { timeout });
await page.waitForFunction(
() => document.querySelector("#loadingStatus")?.dataset.loadStatus === "READY"
&& document.body.innerText.includes("Требует проверки"),
null,
{ timeout },
);
const investigationButtons = await page.locator("[data-open-investigation]").count();
if (investigationButtons > 0) {
await page.locator("[data-open-investigation]").first().click({ timeout });
await page.waitForFunction(
() => document.body && document.body.innerText.includes("Расследования и доказательная база"),
null,
{ timeout },
);
const activeTab = await page.locator(".tab.is-active").innerText({ timeout });
const incidentText = await page.locator("#content").innerText({ timeout });
checks.push({
name: "risk_open_investigation_readonly_navigation",
ok: activeTab.trim() === "Расследования"
&& containsText(incidentText, "Расследование сформировано автоматически")
&& containsText(incidentText, "Номер")
&& containsText(incidentText, "Риск")
&& containsText(incidentText, "Материалы расследования"),
buttons: investigationButtons,
});
} else {
checks.push({
name: "risk_open_investigation_readonly_navigation",
ok: true,
buttons: 0,
skipped: "no risk rows in current report",
});
}
} finally {
await browser.close();
}
const ok = results.every((item) => item.ok) && checks.every((item) => item.ok) && errors.length === 0 && badResponses.length === 0;
const result = {
ok,
generated_at_utc: new Date().toISOString(),
url,
static_tabs: staticTabs,
tabs: results,
checks,
errors,
bad_responses: badResponses,
};
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
process.exitCode = ok ? 0 : 2;
}
main().catch((error) => {
process.stdout.write(`${JSON.stringify({ ok: false, step: smokeStep, error: error.message, stack: error.stack }, null, 2)}\n`);
process.exitCode = 2;
});