import assert from "node:assert/strict" import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js" import { ingestParsedFlowsForServerForTests, resetFlowRingsForTests, } from "./traffic-flow-ingest.js" import { buildFlowAnalytics, formatLiveSseFromBuilder, getFlowMonthly, listFlowClients, listFlowExporters } from "./traffic-flow-analytics.js" import { withPgOrSkip } from "../test/pg.js" import { dbQuery } from "../db/index.js" import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js" import { disableCatalogFetchForTests, resetFlowCatalogForTests, seedFlowCatalogForTests } from "./traffic-flow-classify.js" import { disableRipeEnqueueForTests, disableRipePersistForTests, resetRipeCacheForTests, seedRipeCacheForTests, } from "./traffic-flow-ripe.js" { const liveErr = await formatLiveSseFromBuilder(() => { throw new Error("SQLITE_BUSY") }) assert.equal(liveErr.event, "error") assert.equal((liveErr.data as { error: string }).error, "SQLITE_BUSY") const liveOk = await formatLiveSseFromBuilder(() => ({ ok: true })) assert.equal(liveOk.event, "sample") const liveAsync = await formatLiveSseFromBuilder(async () => ({ uniqueSrc: 3, destinations: [{ id: "8.8.8.8", label: "8.8.8.8", bytes: 1, packets: 1, bps: 1, percent: 100 }], })) assert.equal(liveAsync.event, "sample") assert.notEqual(JSON.stringify(liveAsync.data), "{}") assert.equal((liveAsync.data as { uniqueSrc: number }).uniqueSrc, 3) assert.ok(Array.isArray((liveAsync.data as { destinations: unknown[] }).destinations)) const liveReject = await formatLiveSseFromBuilder(async () => { throw new Error("pg down") }) assert.equal(liveReject.event, "error") assert.equal((liveReject.data as { error: string }).error, "pg down") } if (!(await withPgOrSkip())) { console.log("traffic-flow-analytics.test.ts: skip") process.exit(0) } disableCatalogFetchForTests() resetFlowCatalogForTests() disableRipePersistForTests() resetRipeCacheForTests() disableRipeEnqueueForTests() resetIfaceCacheForTests() resetFlowRingsForTests() seedFlowTopologyForTests({ clientIfaces: new Map(), clientByIface: new Map(), enNodes: [], enHosts: new Set(), jhHosts: new Set(), wanIfaces: new Map(), plane: { clientIfaceNames: new Set(), enHosts: new Set(), jhHosts: new Set() }, }) rememberServerIfaces(7, [ { ".id": "*2", name: "ether1" }, { ".id": "*B", name: "ether2" }, { ".id": "*A", name: "wg-flow" }, ]) ingestParsedFlowsForServerForTests(7, [ { src: "10.1.1.8", dst: "8.8.8.8", proto: 6, srcPort: 51234, dstPort: 443, bytes: 12_000, packets: 10, inIface: "2", outIface: "11", }, { src: "10.1.1.8", dst: "1.1.1.1", proto: 17, srcPort: 53000, dstPort: 53, bytes: 800, packets: 4, inIface: "2", outIface: "", }, ]) try { const all = await buildFlowAnalytics({ minutes: 5, serverId: 7 }) assert.equal(all.applications[0]?.label, "HTTPS") assert.ok(all.protocols.some((p) => p.label === "TCP")) assert.equal(all.ifaces[0]?.name, "ether1") assert.notEqual(all.ifaces[0]?.name, "2") const conv = all.conversationsList[0] assert.ok(conv) assert.equal(conv.inIface, "ether1") assert.equal(conv.inIfaceIndex, "2") assert.equal(conv.application, "HTTPS") assert.ok(!/^\d+$/.test(conv.inIface)) const filtered = await buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "ether1" }) assert.ok(filtered.bytes >= 12_000) assert.equal(filtered.ifaces[0]?.name, "ether1") const miss = await buildFlowAnalytics({ minutes: 5, serverId: 7, iface: "wg-flow" }) assert.equal(miss.conversations, 0) const other = await buildFlowAnalytics({ minutes: 5, serverId: 99 }) assert.equal(other.conversations, 0) } finally { resetFlowRingsForTests() resetIfaceCacheForTests() } resetFlowRingsForTests() resetIfaceCacheForTests() rememberServerIfaces(7, [ { ".id": "*2", name: "ether1" }, { ".id": "*B", name: "ether2" }, { ".id": "*A", name: "wg-flow" }, ]) ingestParsedFlowsForServerForTests(7, [ { src: "10.1.1.8", dst: "8.8.8.8", proto: 6, srcPort: 51234, dstPort: 443, bytes: 12_000, packets: 10, inIface: "2", outIface: "11", }, { src: "10.1.1.8", dst: "8.8.8.8", proto: 6, srcPort: 51234, dstPort: 443, bytes: 9_000, packets: 9, inIface: "11", outIface: "", }, ]) try { const summed = await buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: false }) assert.equal(summed.bytes, 21_000) assert.equal(summed.conversations, 2) const deduped = await buildFlowAnalytics({ minutes: 5, serverId: 7, dedup: true }) assert.equal(deduped.bytes, 12_000) assert.equal(deduped.conversations, 1) assert.equal(deduped.dedupApplied, true) assert.equal(deduped.interfaces.length, 2) } finally { resetFlowRingsForTests() resetIfaceCacheForTests() } resetFlowRingsForTests() resetIfaceCacheForTests() disableRipeEnqueueForTests() seedRipeCacheForTests({ prefix: "8.8.8.0/24", asn: 15169, country: "US", lat: 37.4, lng: -122.1, holder: "GOOGLE", ok: true, fetchedAt: Date.now(), }) seedFlowCatalogForTests({ cidrs: [{ cidr: "8.8.8.0/24", purpose: "steam-gaming" }], }) rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }]) ingestParsedFlowsForServerForTests(7, [ { src: "10.1.1.8", dst: "8.8.8.8", proto: 6, srcPort: 51234, dstPort: 443, bytes: 12_000, packets: 10, inIface: "2", outIface: "", }, ]) try { const geo = await buildFlowAnalytics({ minutes: 5, serverId: 7 }) assert.equal(geo.categories?.[0]?.label, "Игры") assert.ok(geo.asns?.some((r) => r.label.includes("AS15169"))) assert.equal(geo.countries?.[0]?.id, "US") assert.equal(geo.mapEdges?.[0]?.toCountry, "US") assert.ok(geo.mapEdges?.every((e) => e.toCountry !== "?")) assert.equal(geo.conversationsList[0]?.dstCountry, "US") assert.equal(geo.asns?.[0]?.id, "15169") } finally { resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() resetFlowCatalogForTests() } resetFlowRingsForTests() resetIfaceCacheForTests() disableRipeEnqueueForTests() resetRipeCacheForTests() resetFlowCatalogForTests() seedRipeCacheForTests({ prefix: "1.1.1.0/24", asn: 13335, country: "?", lat: null, lng: null, holder: "CLOUDFLARENET, US", ok: true, fetchedAt: Date.now(), }) rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }]) ingestParsedFlowsForServerForTests(7, [ { src: "10.1.1.8", dst: "1.1.1.1", proto: 6, srcPort: 51234, dstPort: 443, bytes: 5000, packets: 5, inIface: "2", outIface: "", }, ]) try { const cf = await buildFlowAnalytics({ minutes: 5, serverId: 7 }) assert.equal(cf.countries?.[0]?.id, "US") assert.ok(cf.mapEdges?.every((e) => e.toCountry !== "?")) assert.equal(cf.services?.[0]?.label, "Cloudflare") assert.equal(cf.categories?.[0]?.label, "CDN") assert.equal(cf.conversationsList[0]?.dstCountry, "US") assert.equal(cf.asns?.[0]?.id, "13335") } finally { resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() resetFlowCatalogForTests() } { ingestParsedFlowsForServerForTests(7, [ { src: "10.1.1.8", dst: "8.8.8.8", proto: 6, srcPort: 51234, dstPort: 443, bytes: 12_000, packets: 10, inIface: "2", outIface: "", }, ]) const degraded = await buildFlowAnalytics({ minutes: 5, serverId: 7, skipHeavy: true }) assert.equal(degraded.degraded, true) assert.equal(degraded.conversationsList.length, 0) assert.ok((degraded.bytes ?? 0) >= 12_000) const exporters = await listFlowExporters(5) const clients = await listFlowClients(5) assert.ok(Array.isArray(exporters.exporters)) assert.ok(Array.isArray(clients.clients)) resetFlowRingsForTests() } { if (await withPgOrSkip()) { const { pool } = await import("../db/index.js") const { ensurePartitionFor } = await import("../db/partitions.js") await ensurePartitionFor(pool, "flow_daily_dims", "month", new Date("2026-09-01T00:00:00Z")) await dbQuery(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day >= '2026-09-01' AND day < '2026-10-01'`) await dbQuery(` INSERT INTO flow_daily_dims (server_id, day, dim, key, bytes, packets) VALUES (7, '2026-09-01', 'country', 'US', 1000, 10), (7, '2026-09-02', 'country', 'US', 500, 5), (7, '2026-09-01', 'service', 'steam', 800, 8), (7, '2026-09-01', 'asn', '15169', 900, 9), (7, '2026-09-01', 'asn', 'other', 100, 1) `) const monthly = await getFlowMonthly("2026-09", 7) assert.equal(monthly.bytes, 1500) assert.equal(monthly.countries[0]?.id, "US") assert.equal(monthly.countries[0]?.bytes, 1500) assert.ok(monthly.asns.some((row) => row.id === "other")) await dbQuery(`DELETE FROM flow_daily_dims WHERE server_id = 7 AND day >= '2026-09-01' AND day < '2026-10-01'`) } } { resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() resetFlowCatalogForTests() disableRipeEnqueueForTests() rememberServerIfaces(7, [ { ".id": "*2", name: "gre-client" }, { ".id": "*3", name: "NSK-SERVHOST-RTK" }, { ".id": "*4", name: "gre-en-nsk" }, ]) const topo: FlowTopology = { clientIfaces: new Map([[7, new Set(["gre-client"])]]), clientByIface: new Map([["7|gre-client", { userId: "u1", login: "alice", name: "Alice", serverId: 7, interfaceName: "gre-client", }]]), enNodes: [{ id: 9, name: "NSK-SERVHOST-RTK", hosts: ["198.51.100.1"] }], enHosts: new Set(["198.51.100.1"]), jhHosts: new Set(["203.0.113.10"]), wanIfaces: new Map(), plane: { clientIfaceNames: new Set(["gre-client"]), enHosts: new Set(["198.51.100.1"]), jhHosts: new Set(["203.0.113.10"]), }, } seedFlowTopologyForTests(topo) seedRipeCacheForTests({ prefix: "173.194.0.0/16", asn: 15169, country: "US", lat: null, lng: null, holder: "GOOGLE", ok: true, fetchedAt: Date.now(), }) ingestParsedFlowsForServerForTests(7, [ { src: "10.100.1.17", dst: "173.194.160.163", proto: 6, srcPort: 51234, dstPort: 443, bytes: 12_000, packets: 10, inIface: "2", outIface: "3", nextHop: "198.51.100.1", }, { src: "203.0.113.10", dst: "198.51.100.1", proto: 47, srcPort: 0, dstPort: 0, bytes: 5_000_000, packets: 4000, inIface: "4", outIface: "4", }, { src: "10.100.1.17", dst: "10.100.1.18", proto: 6, srcPort: 50000, dstPort: 443, bytes: 8000, packets: 8, inIface: "2", outIface: "2", }, ]) try { const def = await buildFlowAnalytics({ minutes: 5, serverId: 7 }) assert.equal(def.bytes, 12_000) assert.equal(def.bytesPayload, 12_000) assert.equal(def.bytesOverlay, 5_000_000) assert.equal(def.bytesMesh, 8000) assert.equal(def.excludeOverlayApplied, true) assert.equal(def.excludeMeshApplied, true) assert.ok(!def.conversationsList.some((r) => r.proto === 47)) assert.equal(def.conversationsList[0]?.service, "Google") assert.equal(def.conversationsList[0]?.category, "Веб") assert.equal(def.conversationsList[0]?.clientName, "Alice") assert.equal(def.conversationsList[0]?.enName, "NSK-SERVHOST-RTK") assert.equal(def.conversationsList[0]?.plane, "payload") const path = def.paths?.[0] assert.ok(path) assert.equal(path.clientName, "Alice") assert.equal(path.enName, "NSK-SERVHOST-RTK") assert.equal(path.dst, "173.194.160.163") const withAll = await buildFlowAnalytics({ minutes: 5, serverId: 7, excludeOverlay: false, excludeMesh: false }) assert.equal(withAll.bytes, 12_000 + 5_000_000 + 8000) assert.ok(withAll.conversationsList.some((r) => r.plane === "overlay")) assert.ok(withAll.conversationsList.some((r) => r.plane === "client_mesh")) } finally { seedFlowTopologyForTests(null) resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() resetFlowCatalogForTests() } } { resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() disableRipeEnqueueForTests() rememberServerIfaces(7, [{ ".id": "*2", name: "ether1" }]) ingestParsedFlowsForServerForTests(7, [ { src: "173.194.151.65", dst: "10.200.100.53", proto: 6, srcPort: 443, dstPort: 57182, bytes: 12_000, packets: 10, inIface: "2", outIface: "2", }, { src: "104.18.35.51", dst: "10.200.100.53", proto: 6, srcPort: 443, dstPort: 53880, bytes: 3_000, packets: 4, inIface: "2", outIface: "2", }, ]) try { const rev = await buildFlowAnalytics({ minutes: 5, serverId: 7 }) const google = rev.conversationsList.find((r) => r.src === "173.194.151.65") const cf = rev.conversationsList.find((r) => r.src === "104.18.35.51") assert.equal(google?.service, "Google") assert.equal(google?.category, "Веб") assert.equal(cf?.service, "Cloudflare") assert.equal(cf?.category, "CDN") } finally { resetFlowRingsForTests() resetIfaceCacheForTests() resetRipeCacheForTests() } } { if (await withPgOrSkip()) { resetFlowRingsForTests() resetIfaceCacheForTests() const sidRow = await dbQuery<{ id: number }>(`SELECT id FROM servers LIMIT 1`) const sid = sidRow.rows[0]?.id if (sid) { const { pool } = await import("../db/index.js") const { ensurePartitionFor } = await import("../db/partitions.js") await ensurePartitionFor(pool, "traffic_samples", "week", new Date()) rememberServerIfaces(sid, [{ ".id": "*4", name: "gre-en-nsk" }]) ingestParsedFlowsForServerForTests(sid, [{ src: "10.100.1.17", dst: "8.8.8.8", proto: 6, srcPort: 1, dstPort: 443, bytes: 100, packets: 1, inIface: "4", outIface: "4", }]) await dbQuery(` INSERT INTO traffic_samples (server_id, interface_name, sampled_at, rx_bytes, tx_bytes, rx_bps, tx_bps) VALUES ($1, 'gre-en-nsk', now(), 9000000, 1000000, 40000000, 2000000) `, [sid]) try { const wire = await buildFlowAnalytics({ minutes: 5, serverId: sid }) assert.ok((wire.bpsWire ?? 0) >= 40_000_000) assert.notEqual(wire.bpsWire, (wire.bytes * 8) / 300) } finally { await dbQuery(`DELETE FROM traffic_samples WHERE server_id = $1 AND interface_name = 'gre-en-nsk'`, [sid]) } } resetFlowRingsForTests() resetIfaceCacheForTests() } } console.log("traffic-flow-analytics.test.ts: ok")