fix(netflow): проверять ответы настроек и подтверждения операций
This commit is contained in:
1 parent
52cb93bf24
commit
4af4fdc544
6 files changed
+91
-26
No files matched your search
@@ -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 и форма ещё требуют доработки.
|
||||
@@ -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 теста повторно прошли.
|
||||
|
||||
@@ -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 и форма ещё требуют доработки.
|
||||
@@ -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 }
|
||||
})
|
||||
+30
-24
@@ -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<TrafficFlowSettingsDto> {
|
||||
return requestJson<TrafficFlowSettingsDto>(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<T>(schema: z.ZodType<T>, 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<unknown>(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<TrafficFlowSettingsDto> {
|
||||
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<string, unknown>)[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<FlowMonthlyDto>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`)
|
||||
}
|
||||
|
||||
export async function purgeTrafficFlowData(baseUrl: string): Promise<FlowPurgeDto> {
|
||||
return requestJson<FlowPurgeDto>(baseUrl, "/api/traffic/flow/purge", { method: "POST" })
|
||||
export async function purgeTrafficFlowData(baseUrl: string, init?: RequestInit): Promise<FlowPurgeDto> {
|
||||
return flowConfirmed(flowPurgeDtoSchema, await flowJson(baseUrl, "/api/traffic/flow/purge", { ...init, method: "POST" }))
|
||||
}
|
||||
|
||||
export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user