fix(netflow): проверять ответы настроек и подтверждения операций
This commit is contained in:
1 parent
52cb93bf24
commit
4af4fdc544
6 files changed
+91
-26
No files matched your search
@@ -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<{
|
||||
|
||||
Reference in new issue
Block a user