Docker images / prepare-release (push) Successful in 8s
Docker images / backend-test (push) Successful in 2m11s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m44s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 10s
- Контракт: countryServiceGroups (группа сервисов страны) в flowMapHopsDto, тип FlowMapCountryServiceGroup. - Бэкенд: вложенная агрегация brand×ISO в том же проходе hops; рёбра страна→сервис, пути с реальным EN; cap 8 / min 4, доля от байтов страны. - Хелпер mapCountryServiceNodeId: id вида cc:us|svc:google, сервисы разных стран не смешиваются. - Карта сети: клик по стране сдвигает колонку стран и раскрывает столбец её сервисов; остальные страны приглушены; панели страны и вложенного сервиса. - Тесты: группы US/US+NL, рёбра от страны, cap/min вложенного слоя; mock countryServiceGroups для демо-режима.
1001 lines
32 KiB
TypeScript
1001 lines
32 KiB
TypeScript
import assert from "node:assert/strict"
|
||
import { rememberServerIfaces, resetIfaceCacheForTests } from "./traffic-flow-ifindex.js"
|
||
import {
|
||
ingestParsedFlowsForServerForTests,
|
||
resetFlowRingsForTests,
|
||
} from "./traffic-flow-ingest.js"
|
||
import {
|
||
buildFlowMapHops,
|
||
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||
MAP_COUNTRY_SERVICE_NODE_CAP,
|
||
MAP_SERVICE_MIN_NODES,
|
||
MAP_SERVICE_NODE_CAP,
|
||
pickMapServices,
|
||
resetFlowMapHopsCacheForTests,
|
||
} from "./traffic-flow-map-hops.js"
|
||
import { withPgOrSkip } from "../test/pg.js"
|
||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||
import {
|
||
disableRipeEnqueueForTests,
|
||
disableRipePersistForTests,
|
||
resetRipeCacheForTests,
|
||
seedRipeCacheForTests,
|
||
} from "./traffic-flow-ripe.js"
|
||
|
||
{
|
||
const googleOnly = pickMapServices(
|
||
[{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }],
|
||
5,
|
||
)
|
||
assert.equal(googleOnly.length, 1)
|
||
assert.equal(googleOnly[0]?.share, 1)
|
||
|
||
const twoNamed = pickMapServices(
|
||
[
|
||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 },
|
||
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 },
|
||
],
|
||
5,
|
||
)
|
||
assert.equal(twoNamed.length, 2)
|
||
|
||
const tinyTail = pickMapServices(
|
||
[
|
||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 },
|
||
...Array.from({ length: 9 }, (_, i) => ({
|
||
id: `svc:t${i}`,
|
||
label: `T${i}`,
|
||
category: "Веб",
|
||
bytes: 100,
|
||
bps: 0,
|
||
share: 0.01,
|
||
})),
|
||
],
|
||
5,
|
||
)
|
||
assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES)
|
||
assert.equal(tinyTail.at(-1)?.id, "svc:t6")
|
||
|
||
const allOff = pickMapServices(
|
||
Array.from({ length: 25 }, (_, i) => ({
|
||
id: `svc:n${i}`,
|
||
label: `N${i}`,
|
||
category: "Веб",
|
||
bytes: 25 - i,
|
||
bps: 0,
|
||
share: 0.04,
|
||
})),
|
||
0,
|
||
)
|
||
assert.equal(allOff.length, MAP_SERVICE_NODE_CAP)
|
||
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
|
||
}
|
||
|
||
{
|
||
const leak = pickMapServices(
|
||
[
|
||
{ id: "svc:other", label: "Прочее", category: "Прочее", bytes: 19_000, bps: 0, share: 19 / 30 },
|
||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 11_000, bps: 0, share: 11 / 30 },
|
||
],
|
||
5,
|
||
)
|
||
assert.equal(leak.reduce((n, s) => n + s.bytes, 0), 30_000)
|
||
const google = leak.find((s) => s.id === "svc:google")
|
||
assert.ok(google)
|
||
assert.ok(google.share < 0.4, "доля от окна хопа, не от named-only")
|
||
assert.ok(leak.some((s) => s.id === "svc:other"), "дыра видна как Прочее")
|
||
}
|
||
|
||
if (!(await withPgOrSkip())) {
|
||
console.log("traffic-flow-map-hops.test.ts: skip")
|
||
process.exit(0)
|
||
}
|
||
|
||
disableCatalogFetchForTests()
|
||
resetFlowCatalogForTests()
|
||
disableRipePersistForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
|
||
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-EN", hosts: ["198.51.100.1"] }],
|
||
enHosts: new Set(["198.51.100.1"]),
|
||
jhHosts: new Set(["203.0.113.10"]),
|
||
wanIfaces: new Map([[3, new Set(["ether1-rt"])]]),
|
||
plane: {
|
||
clientIfaceNames: new Set(["gre-client"]),
|
||
enHosts: new Set(["198.51.100.1"]),
|
||
jhHosts: new Set(["203.0.113.10"]),
|
||
},
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
{ ".id": "*A", name: "wg-flow" },
|
||
])
|
||
rememberServerIfaces(3, [
|
||
{ ".id": "*1", name: "ether1-rt" },
|
||
])
|
||
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
{
|
||
src: "10.100.1.17",
|
||
dst: "8.8.8.8",
|
||
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: "3",
|
||
outIface: "3",
|
||
},
|
||
{
|
||
src: "10.100.1.17",
|
||
dst: "10.100.1.18",
|
||
proto: 6,
|
||
srcPort: 50000,
|
||
dstPort: 443,
|
||
bytes: 8000,
|
||
packets: 8,
|
||
inIface: "2",
|
||
outIface: "2",
|
||
},
|
||
{
|
||
src: "10.255.254.1",
|
||
dst: "10.255.254.2",
|
||
proto: 17,
|
||
srcPort: 4739,
|
||
dstPort: 2055,
|
||
bytes: 400,
|
||
packets: 2,
|
||
inIface: "10",
|
||
outIface: "",
|
||
},
|
||
])
|
||
ingestParsedFlowsForServerForTests(3, [
|
||
{
|
||
src: "192.168.1.10",
|
||
dst: "8.8.4.4",
|
||
proto: 6,
|
||
srcPort: 40000,
|
||
dstPort: 443,
|
||
bytes: 3000,
|
||
packets: 4,
|
||
inIface: "1",
|
||
outIface: "1",
|
||
},
|
||
])
|
||
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const def = await buildFlowMapHops({ minutes: 5 })
|
||
assert.equal(def.excludeOverlayApplied, true)
|
||
assert.equal(def.excludeMeshApplied, true)
|
||
assert.equal(def.dedupApplied, true)
|
||
assert.equal(def.windowSec, 300)
|
||
|
||
const payloadGre = def.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||
assert.ok(payloadGre, "payload JH→EN hop")
|
||
assert.equal(payloadGre.bytes, 12_000)
|
||
assert.equal(payloadGre.bps, (12_000 * 8) / 300)
|
||
assert.equal(payloadGre.bpsFwd, (12_000 * 8) / 300)
|
||
assert.equal(payloadGre.iface, "gre-jh-en")
|
||
|
||
const greIface = def.hops.find((h) => h.kind === "iface" && h.iface === "gre-jh-en" && h.fromId === "7")
|
||
assert.ok(greIface)
|
||
assert.equal(greIface.bytes, 12_000)
|
||
assert.equal(greIface.bpsFwd, (12_000 * 8) / 300)
|
||
|
||
assert.ok(!def.hops.some((h) => h.bytes >= 5_000_000), "overlay GRE proto 47 excluded")
|
||
assert.ok(!def.hops.some((h) => h.iface === "wg-flow"), "mgmt wg-flow excluded")
|
||
const clientIngress = def.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||
assert.ok(clientIngress, "payload ingress on client iface")
|
||
assert.equal(clientIngress.bytes, 12_000)
|
||
|
||
const wan = def.hops.find((h) => h.kind === "wan" && h.fromId === "3" && h.iface === "ether1-rt")
|
||
assert.ok(wan, "WAN hop from home-router")
|
||
assert.equal(wan.bytes, 3000)
|
||
|
||
resetFlowMapHopsCacheForTests()
|
||
const withAll = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false })
|
||
const overlayIface = withAll.hops.find((h) => h.iface === "gre-jh-en" && h.fromId === "7")
|
||
assert.ok(overlayIface && overlayIface.bytes >= 5_000_000)
|
||
const meshIface = withAll.hops.find((h) => h.iface === "gre-client" && h.fromId === "7" && h.kind === "iface")
|
||
assert.ok(meshIface && meshIface.bytes >= 20_000)
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
console.log("traffic-flow-map-hops.test.ts: hops ok")
|
||
|
||
function googleRipe() {
|
||
seedRipeCacheForTests({
|
||
prefix: "8.8.8.0/24",
|
||
asn: 15169,
|
||
country: "US",
|
||
lat: 37.4,
|
||
lng: -122.1,
|
||
holder: "GOOGLE",
|
||
ok: true,
|
||
fetchedAt: Date.now(),
|
||
})
|
||
}
|
||
|
||
function seedRipeAsn(ip: string, asn: number, holder: string) {
|
||
seedRipeCacheForTests({
|
||
prefix: `${ip}/32`,
|
||
asn,
|
||
country: "US",
|
||
lat: 37.4,
|
||
lng: -122.1,
|
||
holder,
|
||
ok: true,
|
||
fetchedAt: Date.now(),
|
||
})
|
||
}
|
||
|
||
function payloadFlow(dst: string, bytes: number) {
|
||
return {
|
||
src: "10.100.1.17",
|
||
dst,
|
||
proto: 6,
|
||
srcPort: 51234,
|
||
dstPort: 443,
|
||
bytes,
|
||
packets: Math.max(1, Math.round(bytes / 1200)),
|
||
inIface: "2",
|
||
outIface: "3",
|
||
nextHop: "198.51.100.1",
|
||
}
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 600),
|
||
payloadFlow("203.0.113.50", 9400),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const six = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
assert.equal(six.totalBytes, 10_000)
|
||
assert.equal(six.namedBytes, 600)
|
||
assert.equal(six.unclassifiedBytes, 9400)
|
||
const google = six.services?.find((s) => s.id === "svc:google")
|
||
const otherSix = six.services?.find((s) => s.id === "svc:other")
|
||
assert.ok(google, "Google ≥ 5%")
|
||
assert.ok(otherSix, "остаток — Прочее")
|
||
assert.ok(google.share >= 0.05 && google.share < 0.1)
|
||
assert.ok(otherSix.share >= 0.9)
|
||
const googleEdge = six.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
|
||
assert.ok(googleEdge)
|
||
assert.equal(googleEdge.clientName, "Alice")
|
||
assert.equal((six.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), 10_000)
|
||
const us = six.countries?.find((s) => s.id === "cc:us")
|
||
assert.ok(us, "Google ripe country US")
|
||
assert.equal(us.label, "US")
|
||
const usEdge = six.countryEdges?.find((e) => e.toId === "cc:us" && e.fromId === "9")
|
||
assert.ok(usEdge)
|
||
assert.ok(!(six.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")), "страны не смешиваются с svc:*")
|
||
const usPath = six.countryPaths?.find((p) => p.serviceId === "cc:us" && p.enId === "9")
|
||
assert.ok(usPath)
|
||
assert.equal(usPath.clientName, "Alice")
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 400),
|
||
payloadFlow("203.0.113.50", 9600),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
assert.equal(four.totalBytes, 10_000)
|
||
assert.equal(four.namedBytes, 400)
|
||
assert.equal(four.unclassifiedBytes, 9600)
|
||
const googleFour = four.services?.find((s) => s.id === "svc:google")
|
||
const otherFour = four.services?.find((s) => s.id === "svc:other")
|
||
assert.ok(googleFour, "бренд виден при 4% от окна (MIN_NODES)")
|
||
assert.ok(otherFour, "Прочее держит остаток окна")
|
||
assert.ok(googleFour.share < 0.1, "доля от totalBytes, не от named")
|
||
assert.ok(otherFour.share > 0.9)
|
||
resetFlowMapHopsCacheForTests()
|
||
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 400),
|
||
payloadFlow("104.18.35.51", 400),
|
||
payloadFlow("203.0.113.50", 9200),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const googleTwo = two.services?.find((s) => s.id === "svc:google")
|
||
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
|
||
const otherTwo = two.services?.find((s) => s.id === "svc:other")
|
||
assert.ok(googleTwo, "Google среди брендов")
|
||
assert.ok(cfTwo, "Cloudflare среди брендов")
|
||
assert.ok(otherTwo, "Прочее")
|
||
assert.ok(googleTwo.share > 0.03 && googleTwo.share < 0.05)
|
||
assert.ok(cfTwo.share > 0.03 && cfTwo.share < 0.05)
|
||
assert.ok(otherTwo.share > 0.9)
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP")
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("162.254.192.71", 2000),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте")
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("64.233.161.1", 10_000),
|
||
payloadFlow("203.0.113.50", 20_000),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const leak = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const hop = leak.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||
assert.ok(hop, "GRE hop JH→EN")
|
||
assert.equal(hop.bytes, 30_000)
|
||
assert.equal(leak.totalBytes, 30_000)
|
||
assert.equal(leak.namedBytes, 10_000)
|
||
assert.equal(leak.unclassifiedBytes, 20_000)
|
||
const yt = leak.services?.find((s) => s.id === "svc:youtube")
|
||
const other = leak.services?.find((s) => s.id === "svc:other")
|
||
assert.ok(yt, "64.233:443 без RIPE → YouTube")
|
||
assert.ok(other, "dest без бренда → Прочее")
|
||
assert.equal(yt.bytes, 10_000)
|
||
assert.equal(other.bytes, 20_000)
|
||
assert.ok(Math.abs(yt.share - 10 / 30) < 0.01)
|
||
assert.ok(Math.abs(other.share - 20 / 30) < 0.01)
|
||
assert.equal((leak.serviceEdges ?? []).reduce((n, e) => n + e.bytes, 0), hop.bytes)
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
|
||
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
|
||
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
|
||
{ ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" },
|
||
{ ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" },
|
||
{ ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" },
|
||
{ ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" },
|
||
{ ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" },
|
||
{ ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" },
|
||
{ ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" },
|
||
]
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 5000),
|
||
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const ids = new Set((top.services ?? []).map((s) => s.id))
|
||
assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте")
|
||
assert.ok(ids.has("svc:google"))
|
||
for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id)
|
||
assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт")
|
||
assert.ok(!ids.has("svc:riot"))
|
||
} finally {
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
{
|
||
src: "203.0.113.10",
|
||
dst: "198.51.100.1",
|
||
proto: 47,
|
||
srcPort: 0,
|
||
dstPort: 0,
|
||
bytes: 9_000,
|
||
packets: 90,
|
||
inIface: "3",
|
||
outIface: "3",
|
||
},
|
||
payloadFlow("203.0.113.50", 1000),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const greOnly = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, minSharePct: 0 })
|
||
assert.ok(!(greOnly.services ?? []).some((s) => s.label === "GRE"), "GRE is not a destination service")
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
{
|
||
src: "10.100.1.17",
|
||
dst: "8.8.8.8",
|
||
proto: 6,
|
||
srcPort: 51234,
|
||
dstPort: 443,
|
||
bytes: 4_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: 2_000_000,
|
||
packets: 400,
|
||
inIface: "3",
|
||
outIface: "3",
|
||
},
|
||
{
|
||
src: "10.200.100.53",
|
||
dst: "10.200.100.1",
|
||
proto: 6,
|
||
srcPort: 53880,
|
||
dstPort: 443,
|
||
bytes: 3_000,
|
||
packets: 8,
|
||
inIface: "2",
|
||
outIface: "3",
|
||
nextHop: "198.51.100.1",
|
||
natDst: "8.8.8.8",
|
||
natDstPort: 443,
|
||
},
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const path = await buildFlowMapHops({ minutes: 5, excludeOverlay: false, excludeMesh: false, minSharePct: 0 })
|
||
const hop = path.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||
assert.ok(hop, "hop JH→EN")
|
||
const google = path.services?.find((s) => s.id === "svc:google")
|
||
assert.ok(google, "сервис Google")
|
||
assert.equal(google.bytes, 7_000)
|
||
assert.ok(!(path.services ?? []).some((s) => s.label === "GRE"), "GRE не dest")
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
{
|
||
src: "173.194.151.65",
|
||
dst: "10.200.100.53",
|
||
proto: 6,
|
||
srcPort: 443,
|
||
dstPort: 57182,
|
||
bytes: 9_000,
|
||
packets: 90,
|
||
inIface: "2",
|
||
outIface: "3",
|
||
nextHop: "198.51.100.1",
|
||
},
|
||
{
|
||
src: "104.18.35.51",
|
||
dst: "10.200.100.53",
|
||
proto: 6,
|
||
srcPort: 443,
|
||
dstPort: 53880,
|
||
bytes: 1_000,
|
||
packets: 10,
|
||
inIface: "2",
|
||
outIface: "3",
|
||
nextHop: "198.51.100.1",
|
||
},
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const rev = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
assert.ok(rev.services?.some((s) => s.id === "svc:youtube"), "реверс googlevideo:443 → YouTube")
|
||
assert.ok(rev.services?.some((s) => s.id === "svc:cloudflare"), "реверс Cloudflare:443 → 10.x")
|
||
assert.ok(rev.serviceEdges?.some((e) => e.toId === "svc:youtube" && e.fromId === "9"))
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*1", name: "SWE-VEESP" },
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 500),
|
||
{
|
||
src: "173.194.151.65",
|
||
dst: "10.200.100.53",
|
||
proto: 6,
|
||
srcPort: 443,
|
||
dstPort: 57182,
|
||
bytes: 8_000,
|
||
packets: 80,
|
||
inIface: "1",
|
||
outIface: "1",
|
||
nextHop: "",
|
||
},
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const wan = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const googleEdge = wan.serviceEdges?.find((e) => e.toId === "svc:google")
|
||
assert.ok(googleEdge, "Google с WAN JH")
|
||
assert.equal(googleEdge.fromId, "9", "якорь на EN, не на JH")
|
||
assert.ok(!(wan.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
|
||
const viaGre = wan.serviceEdges?.find((e) => e.toId === "svc:google")
|
||
assert.ok(viaGre?.clients?.some((c) => c.name === "Alice") || viaGre?.clientName === "Alice")
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*1", name: "SWE-VEESP" },
|
||
])
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
{
|
||
src: "173.194.151.65",
|
||
dst: "10.200.100.53",
|
||
proto: 6,
|
||
srcPort: 443,
|
||
dstPort: 57182,
|
||
bytes: 9_000,
|
||
packets: 80,
|
||
inIface: "1",
|
||
outIface: "1",
|
||
nextHop: "",
|
||
},
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const wanOnly = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const googleEdge = wanOnly.serviceEdges?.find((e) => e.toId === "svc:youtube")
|
||
assert.ok(googleEdge, "YouTube WAN без GRE payload")
|
||
assert.equal(googleEdge.fromId, "9", "единственный EN, даже без nextHop")
|
||
assert.ok(googleEdge.bps > 0, "скорость на hop EN→сервис")
|
||
assert.ok(!(wanOnly.serviceEdges ?? []).some((e) => e.fromId === "7"), "нет пунктира с JH")
|
||
const googlePath = wanOnly.servicePaths?.find((p) => p.serviceId === "svc:youtube")
|
||
assert.ok(googlePath, "путь WAN YouTube")
|
||
assert.equal(googlePath.viaId, "7", "via = JH exporter")
|
||
assert.equal(googlePath.enId, "9", "якорь EN")
|
||
assert.ok(googlePath.bps > 0, "скорость на пути клиента")
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
rememberServerIfaces(9, [
|
||
{ ".id": "*1", name: "ether1" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [payloadFlow("8.8.8.8", 12_000)])
|
||
ingestParsedFlowsForServerForTests(9, [{
|
||
src: "10.100.1.17",
|
||
dst: "8.8.8.8",
|
||
proto: 6,
|
||
srcPort: 51234,
|
||
dstPort: 443,
|
||
bytes: 12_000,
|
||
packets: 10,
|
||
inIface: "1",
|
||
outIface: "1",
|
||
nextHop: "",
|
||
}])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const dual = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const gre = dual.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||
assert.ok(gre, "GRE JH→EN сохранён")
|
||
assert.equal(gre.bytes, 12_000)
|
||
const googlePaths = (dual.servicePaths ?? []).filter((p) => p.serviceId === "svc:google")
|
||
assert.equal(googlePaths.length, 1, "один путь без копии EN")
|
||
assert.equal(googlePaths[0]?.clientId, "u1")
|
||
assert.equal(googlePaths[0]?.viaId, "7")
|
||
const googleEdge = dual.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
|
||
assert.ok(googleEdge)
|
||
assert.equal(googleEdge.bytes, 12_000)
|
||
assert.equal(googleEdge.bps, googlePaths[0]?.bps)
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 8_000),
|
||
{
|
||
src: "8.8.8.8",
|
||
dst: "10.100.1.17",
|
||
proto: 6,
|
||
srcPort: 443,
|
||
dstPort: 51234,
|
||
bytes: 4_000,
|
||
packets: 8,
|
||
inIface: "3",
|
||
outIface: "2",
|
||
nextHop: "",
|
||
},
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const bothDir = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const gre = bothDir.hops.find((h) => h.kind === "gre" && h.fromId === "7" && h.toId === "9")
|
||
assert.ok(gre, "GRE-hop при fwd/rev")
|
||
const googlePaths = (bothDir.servicePaths ?? []).filter((p) => p.serviceId === "svc:google")
|
||
assert.equal(googlePaths.length, 1, "fwd+rev — один клиент")
|
||
assert.equal(googlePaths[0]?.clientId, "u1")
|
||
assert.ok(!(bothDir.servicePaths ?? []).some((p) => p.serviceId === "svc:google" && p.clientId === "—"))
|
||
const googleEdge = bothDir.serviceEdges?.find((e) => e.toId === "svc:google" && e.fromId === "9")
|
||
assert.ok(googleEdge)
|
||
assert.equal(googleEdge.bytes, 12_000)
|
||
assert.equal(googleEdge.bps, googlePaths[0]?.bps)
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
seedRipeCacheForTests({
|
||
prefix: "185.45.12.0/24",
|
||
asn: 13335,
|
||
country: "NL",
|
||
lat: 52.3,
|
||
lng: 4.9,
|
||
holder: "CLOUDFLARENET, NL",
|
||
ok: true,
|
||
fetchedAt: Date.now(),
|
||
})
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 5000),
|
||
payloadFlow("185.45.12.10", 5000),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const split = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const us = split.countries?.find((s) => s.id === "cc:us")
|
||
const nl = split.countries?.find((s) => s.id === "cc:nl")
|
||
assert.ok(us, "US из ripe Google")
|
||
assert.ok(nl, "NL из ripe Cloudflare")
|
||
assert.equal(us.bytes, 5000)
|
||
assert.equal(nl.bytes, 5000)
|
||
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:us" && e.fromId === "9"))
|
||
assert.ok(split.countryEdges?.some((e) => e.toId === "cc:nl" && e.fromId === "9"))
|
||
assert.ok(!(split.countryEdges ?? []).some((e) => e.toId.startsWith("svc:")))
|
||
assert.ok(split.serviceEdges?.some((e) => e.toId === "svc:google"))
|
||
assert.ok(!(split.serviceEdges ?? []).some((e) => e.toId.startsWith("cc:")))
|
||
assert.ok(split.countryPaths?.some((p) => p.serviceId === "cc:nl" && p.enId === "9"))
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 600),
|
||
payloadFlow("203.0.113.50", 9400),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const nested = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const usGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||
assert.ok(usGroup, "группа сервисов cc:us")
|
||
const nestedGoogle = usGroup.services.find((s) => s.id === "cc:us|svc:google")
|
||
assert.ok(nestedGoogle, "cc:us|svc:google в группе")
|
||
assert.equal(nestedGoogle.label, "Google")
|
||
assert.ok(Math.abs(nestedGoogle.share - 1) < 0.01, "доля от байтов страны (600/600), не окна")
|
||
const nestedEdge = usGroup.edges.find((e) => e.toId === "cc:us|svc:google")
|
||
assert.ok(nestedEdge)
|
||
assert.equal(nestedEdge.fromId, "cc:us", "ребро вложенного слоя: страна → сервис")
|
||
assert.equal(nestedEdge.bytes, 600)
|
||
assert.ok(!usGroup.edges.some((e) => e.fromId.startsWith("svc:")), "fromId вложенных рёбер не svc:*")
|
||
const nestedPath = usGroup.paths.find((p) => p.serviceId === "cc:us|svc:google")
|
||
assert.ok(nestedPath)
|
||
assert.equal(nestedPath.enId, "9", "путь держит реальный EN для подсветки HR→JH→EN")
|
||
assert.equal(nestedPath.clientName, "Alice")
|
||
const otherGroup = nested.countryServiceGroups?.find((g) => g.countryId === "cc:other")
|
||
assert.ok(otherGroup, "группа cc:other (Прочее-страна)")
|
||
assert.ok(otherGroup.services.some((s) => s.id === "cc:other|svc:other"))
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
seedRipeCacheForTests({
|
||
prefix: "185.45.12.0/24",
|
||
asn: 13335,
|
||
country: "NL",
|
||
lat: 52.3,
|
||
lng: 4.9,
|
||
holder: "CLOUDFLARENET, NL",
|
||
ok: true,
|
||
fetchedAt: Date.now(),
|
||
})
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 5000),
|
||
payloadFlow("185.45.12.10", 5000),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const nestedSplit = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const usNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||
const nlNested = nestedSplit.countryServiceGroups?.find((g) => g.countryId === "cc:nl")
|
||
assert.ok(usNested && nlNested, "группы у обеих стран")
|
||
assert.ok(usNested.services.every((s) => s.id.startsWith("cc:us|")), "сервисы US только cc:us|*")
|
||
assert.ok(nlNested.services.every((s) => s.id.startsWith("cc:nl|")), "сервисы NL только cc:nl|*")
|
||
assert.ok(usNested.services.some((s) => s.id === "cc:us|svc:google"), "Google в US")
|
||
assert.ok(nlNested.services.some((s) => s.id === "cc:nl|svc:cloudflare"), "Cloudflare в NL")
|
||
assert.ok(!nlNested.services.some((s) => s.id === "cc:us|svc:google"), "Google US не утек в NL")
|
||
assert.ok(usNested.edges.every((e) => e.fromId === "cc:us"))
|
||
assert.ok(nlNested.edges.every((e) => e.fromId === "cc:nl"))
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
disableRipeEnqueueForTests()
|
||
seedFlowTopologyForTests(topo)
|
||
rememberServerIfaces(7, [
|
||
{ ".id": "*2", name: "gre-client" },
|
||
{ ".id": "*3", name: "gre-jh-en" },
|
||
])
|
||
googleRipe()
|
||
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
|
||
ingestParsedFlowsForServerForTests(7, [
|
||
payloadFlow("8.8.8.8", 5000),
|
||
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
|
||
])
|
||
try {
|
||
resetFlowMapHopsCacheForTests()
|
||
const nestedTop = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||
const usTop = nestedTop.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||
assert.ok(usTop)
|
||
assert.equal(
|
||
usTop.services.length,
|
||
MAP_COUNTRY_SERVICE_MIN_NODES,
|
||
"мелкий хвост держится минимумом узлов внутри страны",
|
||
)
|
||
assert.ok(usTop.services.some((s) => s.id === "cc:us|svc:google"))
|
||
resetFlowMapHopsCacheForTests()
|
||
const nestedAll = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||
const usAll = nestedAll.countryServiceGroups?.find((g) => g.countryId === "cc:us")
|
||
assert.ok(usAll)
|
||
assert.equal(usAll.services.length, MAP_COUNTRY_SERVICE_NODE_CAP, "cap вложенного слоя = 8")
|
||
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:epic"), "ранг 9+ скрыт")
|
||
assert.ok(!usAll.services.some((s) => s.id === "cc:us|svc:riot"))
|
||
} finally {
|
||
seedFlowTopologyForTests(null)
|
||
resetFlowRingsForTests()
|
||
resetIfaceCacheForTests()
|
||
resetRipeCacheForTests()
|
||
resetFlowCatalogForTests()
|
||
}
|
||
|
||
console.log("traffic-flow-map-hops.test.ts: ok")
|