From 4af4fdc544761c9b98641425b3d79f4770c29c4b Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 12:29:05 +0700 Subject: [PATCH] =?UTF-8?q?fix(netflow):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D1=8F=D1=82=D1=8C=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82=D1=8B?= =?UTF-8?q?=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=BE=D0=BF=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ux-ui-plan.md | 3 ++ docs/ux-ui-progress.md | 9 +++- docs/ux-ui-stage-11b.md | 3 ++ shared/api/traffic-flow-settings.test.ts | 41 +++++++++++++++++ shared/api/traffic-flow.ts | 54 +++++++++++++---------- tests/e2e/collector-probes-tables.spec.ts | 7 ++- 6 files changed, 91 insertions(+), 26 deletions(-) create mode 100644 shared/api/traffic-flow-settings.test.ts diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 3526b232..d0df230e 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -249,3 +249,6 @@ REST запрос → ожидание → подтверждённый output / | Partial job | [PNG](ux-ui/screens/backups-partial-job-desktop.png) | [390](ux-ui/screens/backups-partial-job-mobile.png), [320](ux-ui/screens/backups-partial-job-narrow.png) | | Initial error | [PNG](ux-ui/screens/backups-initial-error-desktop.png) | [390](ux-ui/screens/backups-initial-error-mobile.png), [320](ux-ui/screens/backups-initial-error-narrow.png) | + + +Продолжение 2026-10-03: завершён 11Б.3.4.4.1 (строгие NetFlow API, 5 unit и 3 collector/probes E2E, typecheck/ESLint). Следующий блок — 11Б.3.4.4.2: backend экспорт/права и подтверждение сохранения отдельно от запуска listener. Purge и форма ещё требуют доработки. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 76acf9ff..3f1c354d 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -28,7 +28,7 @@ | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | -| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.3; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.4.4.1; status/history/KPI, strict settings API основа и защита каталога Evolution проверены; далее readers/dirty формы | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.3.4.4.1 — строгие ответы NetFlow (2026-10-03) + +- Settings GET/PUT, генерация ключей, экспорт и purge проверяют фактический DTO и буквальное подтверждение. PUT отправляет только валидный непустой patch и проверяет каждый переданный параметр в ответе; ошибки не запускают повторный запрос. +- Проверены диапазон портов, согласованность private/public key и listener/address, дата, уникальные peers и файлы экспорта. Нулевые счётчики и false сохраняются, неизвестные поля не подменяются значениями по умолчанию. +- 5 API unit tests, полный frontend typecheck, ESLint и 3 существующих collector/probes E2E прошли. Старый scheduler fixture обновлён под подтверждённый status/run-now DTO. Реальные БД/UDP/ключи не проверены; форма NetFlow ещё не принята. +- Далее 11Б.3.4.4.2: backend права private export, GET без создания ключей и отдельный результат сохранения/запуска listener. Затем атомарность purge и форма. + ## Блок 11Б.3.4.3 — локальная приёмка GeoIP (2026-10-03) - 6 backend source E2E проверили поздние GET/PUT/POST, success/error старого адреса не меняют новый раздел. POST assertions уточнены для сообщения с фактическими счётчиками; оба POST source теста повторно прошли. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index 1764232e..c946fbfa 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -177,3 +177,6 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S - Первоначальный overflow assertion выявлял также штатную отрицательную границу Frame (+1px); допуск ограничен 2px, body не переполняется. Снимок 320 просмотрен и обнаруженное фактическое сжатие заголовка исправлено, тесты повторены. Это не утверждение о всей NetFlow панели. - ESLint и production frontend/contracts build прошли. Backend JWT gate/permissions/build приняты в 11Б.3.4.1; реальные Portal/DB/mmdb не проверены. Unknown guard текущего раздела не долговечен между reload/navigation. - Далее 11Б.3.4.4: строгие NetFlow settings/keys/export/purge API и backend эффекты. Выявлены создание ключей в GET host-files, выдача конфигов с private key по traffic read и маскировка успешного purge ошибкой события; также требуется проверка атомарности purge. UI NetFlow ещё не принят. + + +Продолжение 2026-10-03: завершён 11Б.3.4.4.1 (строгие NetFlow API, 5 unit и 3 collector/probes E2E, typecheck/ESLint). Следующий блок — 11Б.3.4.4.2: backend экспорт/права и подтверждение сохранения отдельно от запуска listener. Purge и форма ещё требуют доработки. diff --git a/shared/api/traffic-flow-settings.test.ts b/shared/api/traffic-flow-settings.test.ts new file mode 100644 index 00000000..1bb0d946 --- /dev/null +++ b/shared/api/traffic-flow-settings.test.ts @@ -0,0 +1,41 @@ +import assert from "node:assert/strict" +import { test } from "node:test" +import { getTrafficFlowSettings, putTrafficFlowSettings, generateTrafficFlowKeys, getTrafficFlowHostFiles, purgeTrafficFlowData } from "./traffic-flow" +const base = "http://fixture.test" +const settings = { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 0, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] } +const keyed = { ...settings, hasHostPrivateKey: true, hostPublicKey: "fixture-public-only" } +const json = (value: unknown) => new Response(JSON.stringify(value)) +test("NetFlow GET requires complete DTO and preserves zero/false/known empty peers", async () => { + const original = globalThis.fetch + try { globalThis.fetch = async () => json(settings); assert.deepEqual(await getTrafficFlowSettings(base), settings) + for (const raw of [{}, null, { ...settings, enabled: undefined }, { ...settings, flowListenPort: 65536 }, { ...settings, hasHostPrivateKey: true }, { ...settings, listenerBound: true }, { ...settings, lastDatagramAt: "bad" }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getTrafficFlowSettings(base), /не подтверждены/) } + } finally { globalThis.fetch = original } +}) +test("NetFlow PUT validates before HTTP and requires literal ack and exact sent fields", async () => { + const original = globalThis.fetch; let calls = 0 + try { + globalThis.fetch = async (_url, init) => { calls++; assert.equal(init?.method, "PUT"); assert.deepEqual(JSON.parse(String(init?.body)), { enabled: false }); return json({ ok: true, settings }) }; await putTrafficFlowSettings(base, { enabled: false }); assert.equal(calls, 1) + for (const raw of [{}, { ok: false, settings }, { ok: true, settings: { ...settings, enabled: true } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => putTrafficFlowSettings(base, { enabled: false })) } + for (const patch of [{}, { flowListenPort: 65536 }, { wgListenPort: 30.5 }]) { calls = 0; globalThis.fetch = async () => { calls++; return json({}) }; await assert.rejects(() => putTrafficFlowSettings(base, patch)); assert.equal(calls, 0) } + } finally { globalThis.fetch = original } +}) +test("NetFlow keys require real created flag and same public key with configured private key", async () => { + const original = globalThis.fetch + try { globalThis.fetch = async () => json({ ok: true, created: false, publicKey: keyed.hostPublicKey, settings: keyed }); assert.equal((await generateTrafficFlowKeys(base)).created, false) + for (const raw of [{}, { ok: true, publicKey: keyed.hostPublicKey, settings: keyed }, { ok: true, created: true, publicKey: "other", settings: keyed }, { ok: true, created: true, publicKey: "fixture", settings }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => generateTrafficFlowKeys(base)) } + } finally { globalThis.fetch = original } +}) +test("NetFlow export validates files, identity and leaf filename without synthesizing data", async () => { + const original = globalThis.fetch; const file = { id: "wg", label: "WG", filename: "wg-flow.conf", code: "fixture-only" } + try { globalThis.fetch = async () => json({ files: [file] }); assert.deepEqual((await getTrafficFlowHostFiles(base)).files, [file]) + for (const raw of [{}, { files: [] }, { files: [file, file] }, { files: [{ ...file, filename: "../wg.conf" }] }, { files: [{ ...file, code: "" }] }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getTrafficFlowHostFiles(base)) } + } finally { globalThis.fetch = original } +}) +test("NetFlow purge requires confirmed actual counts and never repeats an unknown POST", async () => { + const original = globalThis.fetch + try { + const receipt = { ok: true, deleted: { buckets: 0, minuteStats: 0, minuteDims: 0, dailyDims: 0 }, fileBytesBefore: 0, fileBytesAfter: 0, vacuumed: false } + globalThis.fetch = async () => json(receipt); assert.deepEqual(await purgeTrafficFlowData(base), receipt) + for (const raw of [{}, { ...receipt, ok: false }, { ...receipt, deleted: { buckets: -1 } }]) { let calls = 0; globalThis.fetch = async () => { calls++; return json(raw) }; await assert.rejects(() => purgeTrafficFlowData(base)); assert.equal(calls, 1) } + } finally { globalThis.fetch = original } +}) diff --git a/shared/api/traffic-flow.ts b/shared/api/traffic-flow.ts index 89ff5e59..3918db90 100644 --- a/shared/api/traffic-flow.ts +++ b/shared/api/traffic-flow.ts @@ -11,35 +11,41 @@ import type { TrafficFlowSettingsDto, TrafficFlowSettingsPatch, } from "@mmapp/contracts/traffic-flow" +import { z } from "zod" +import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema } from "@mmapp/contracts/traffic-flow" import { requestJson } from "@/shared/api/http-client" export type { TrafficFlowHostFile } -export async function getTrafficFlowSettings(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/traffic/flow/settings") +const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow") +const flowPatchSchema = trafficFlowSettingsPatchSchema.extend({ flowListenPort: z.number().int().min(1).max(65535).optional(), wgListenPort: z.number().int().min(1).max(65535).optional() }).strict() +function flowConfirmed(schema: z.ZodType, raw: unknown): T { + const parsed = schema.safeParse(raw) + if (!parsed.success) throw new Error("Данные NetFlow не подтверждены: получен некорректный ответ сервера") + return parsed.data } - -export async function putTrafficFlowSettings( - baseUrl: string, - patch: TrafficFlowSettingsPatch, -): Promise<{ ok: boolean; settings: TrafficFlowSettingsDto }> { - return requestJson(baseUrl, "/api/traffic/flow/settings", { - method: "PUT", - body: JSON.stringify(patch), - }) +function flowJson(baseUrl: string, path: string, init?: RequestInit) { + return requestJson(baseUrl, path, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) }) } - -export async function generateTrafficFlowKeys(baseUrl: string): Promise<{ - ok: boolean - created: boolean - publicKey: string - settings: TrafficFlowSettingsDto -}> { - return requestJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { method: "POST" }) +export async function getTrafficFlowSettings(baseUrl: string, init?: RequestInit): Promise { + return flowConfirmed(flowSettingsSchema, await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "GET" })) } - -export async function getTrafficFlowHostFiles(baseUrl: string): Promise<{ files: TrafficFlowHostFile[] }> { - return requestJson(baseUrl, "/api/traffic/flow/host-files") +export async function putTrafficFlowSettings(baseUrl: string, patch: TrafficFlowSettingsPatch, init?: RequestInit) { + const valid = flowPatchSchema.safeParse(patch) + if (!valid.success || !Object.values(valid.data).some(value => value !== undefined)) throw new Error("Укажите допустимые настройки NetFlow") + const body = valid.data + const response = flowConfirmed(z.object({ ok: z.literal(true), settings: flowSettingsSchema, listenerError: z.string().optional() }), await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "PUT", body: JSON.stringify(body) })) + if (Object.entries(body).some(([field, value]) => value !== undefined && (response.settings as Record)[field] !== value)) throw new Error("Сохранение NetFlow не подтверждено: сервер вернул другие настройки") + return response +} +export async function generateTrafficFlowKeys(baseUrl: string, init?: RequestInit) { + const schema = z.object({ ok: z.literal(true), created: z.boolean(), publicKey: z.string().min(1), settings: flowSettingsSchema }).refine(data => data.settings.hasHostPrivateKey && data.publicKey === data.settings.hostPublicKey) + return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { ...init, method: "POST" })) +} +export async function getTrafficFlowHostFiles(baseUrl: string, init?: RequestInit): Promise<{ files: TrafficFlowHostFile[] }> { + const file = trafficFlowHostFileSchema.extend({ id: z.string().min(1), label: z.string().min(1), filename: z.string().min(1).refine(name => !/[\\/\r\n]/.test(name)), code: z.string().min(1) }) + const schema = z.object({ files: z.array(file).min(1).refine(files => new Set(files.map(item => item.id)).size === files.length && new Set(files.map(item => item.filename)).size === files.length) }) + return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/host-files", { ...init, method: "GET" })) } export async function applyTrafficFlowOverlay( @@ -142,8 +148,8 @@ export async function getFlowMonthly( return requestJson(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`) } -export async function purgeTrafficFlowData(baseUrl: string): Promise { - return requestJson(baseUrl, "/api/traffic/flow/purge", { method: "POST" }) +export async function purgeTrafficFlowData(baseUrl: string, init?: RequestInit): Promise { + return flowConfirmed(flowPurgeDtoSchema, await flowJson(baseUrl, "/api/traffic/flow/purge", { ...init, method: "POST" })) } export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{ diff --git a/tests/e2e/collector-probes-tables.spec.ts b/tests/e2e/collector-probes-tables.spec.ts index fa59748a..4a15e598 100644 --- a/tests/e2e/collector-probes-tables.spec.ts +++ b/tests/e2e/collector-probes-tables.spec.ts @@ -1,3 +1,4 @@ +import { SCHEDULER_JOB_KEYS } from "../../lib/scheduler-settings" import { test, expect, type Page } from "@playwright/test" import type { SpeedProbeApiRow } from "../../components/data-grids/probes-speed-probes-data-grid" @@ -7,11 +8,14 @@ async function setup(page: Page, path: string) { dstInterface: "ether1", protocol: "tcp", direction: "both", durationSec: "10", enabled: true, lastRunAt: null, lastTxAvgMbps: null, lastRxAvgMbps: null, lastStatus: null, lastError: null, })) + const jobs = SCHEDULER_JOB_KEYS.map(jobKey => ({ jobKey, enabled: true, intervalSec: 300, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null })) const calls: string[] = [] await page.route("**/health", route => route.fulfill({ json: { ok: true } })) await page.route("**/api/**", route => { const path = new URL(route.request().url()).pathname if (route.request().method() === "POST") calls.push(path) + if (path === "/api/scheduler/status") return route.fulfill({ json: { jobs } }) + if (path === "/api/scheduler/jobs/alert_engine/run-now") return route.fulfill({ json: { ok: true, result: { status: "ok", error: null, result: { v: 1, job: "alert_engine", sampledAt: "2026-10-03T00:00:00Z", rulesChecked: 0, standaloneFires: 0, groupFires: 0, skippedNoTelegram: false } }, status: { jobs } } }) return route.fulfill({ json: path === "/api/uptime/speed-probes" ? { probes } : path === "/api/auth/config" ? { required: false } : path === "/api/servers" ? [] : path === "/api/evobgp/settings" ? { enabled: false, baseUrl: "", secretConfigured: false } @@ -26,7 +30,7 @@ async function setup(page: Page, path: string) { : path === "/api/scheduler/runs" ? { runs: [] } : path.endsWith("/settings") || path.endsWith("/renew-settings") ? { enabled: true, intervalSec: 300, retentionDays: 7, probeIntervalSec: 15, speedIntervalSec: 60, - renewBeforeDays: 30, scheduler: { jobs: [] }, + resourcesEnabled: true, pingEnabled: true, speedEnabled: true, lastCollectedAt: null, lastDurationMs: 0, lastError: null, renewBeforeDays: 30, scheduler: { jobs: [] }, } : {} }) }) await page.goto(path) @@ -52,6 +56,7 @@ test("Сбор данных: все 11 задач и запуск выбранн await page.getByRole("button", { name: "Запустить: Оповещения", exact: true }).click() await expect.poll(() => calls).toContain("/api/scheduler/jobs/alert_engine/run-now") await expect(rows).toHaveCount(11) + await expect(page.getByText("Запуск подтверждён: Оповещения", { exact: true })).toBeVisible() }) test("Демо-расписание: сортировка интервала и удаление выбранного правила", async ({ page }) => {