498 lines
21 KiB
JavaScript
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;
|
|
});
|