fix(evolution): проверять каталог и сохранять неизвестные счётчики

This commit is contained in:
Denozordec committed 2026-10-03 13:51:07 +07:00
1 parent 9db1471b2c
commit 7e3cb754b0
16 files changed
+140 -82

No files matched your search

@@ -0,0 +1,32 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "./evobgp-catalog.js"
const empty = { modules:{items:[]}, domains:{items:[]}, ip_ranges:{items:[]}, asns:{items:[]}, communities:{items:[]} }
const asn = {module_id:"m",entry:{id:"a",asn:64512,community_id:"c"}}
test("upstream catalog requires every collection; known empty stays empty", () => {
assert.deepEqual(parseEvoCatalogRaw(empty),empty)
for(const section of Object.keys(empty)) {
for(const bad of [undefined,null,{}, {items:null}]) assert.throws(()=>parseEvoCatalogRaw({...empty,[section]:bad}), /не подтверждён/)
}
for(const raw of [null,{},[],{error:"upstream unavailable"}]) assert.throws(()=>parseEvoCatalogRaw(raw), /не подтверждён/)
})
test("row identities, ASN range and integral counts are checked before mapping", () => {
for(const entry of [{...asn.entry,id:""},{...asn.entry,asn:0},{...asn.entry,asn:4294967296},...[-1,1.5,Number.MAX_SAFE_INTEGER+1,"5"].map(prefix_count=>({...asn.entry,prefix_count}))]) assert.throws(()=>parseEvoCatalogRaw({...empty,asns:{items:[{...asn,entry}]}}), /не подтверждён/)
assert.throws(()=>parseEvoCatalogRaw({...empty,asns:{items:[asn,asn]}}))
assert.throws(()=>parseEvoCatalogRaw({...empty,domains:{items:[{module_id:"m",entry:{id:"d"}}]}}))
assert.throws(()=>parseEvoCatalogRaw({...empty,modules:{items:[{id:"m",name:"module"}]}}))
assert.equal(parseEvoCatalogRaw({...empty,asns:{items:[asn,{...asn,module_id:"other"}]}}).asns.items.length,2)
})
test("unknown ASN count propagates to its community, known zero stays zero", () => {
for(const prefix_count of [undefined,null]) {
const raw={...empty,domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},asns:{items:[{...asn,entry:{...asn.entry,prefix_count}},{...asn,entry:{...asn.entry,id:"zero",community_id:"z",prefix_count:0}}]}}
const totals=aggregateCommunityRouteCounts(parseEvoCatalogRaw(raw))
assert.equal(totals.get("c"),null);assert.equal(totals.get("z"),0)
}
})
test("known sums include domains, CIDR and prefixes; safe integer overflow fails", () => {
const raw={...empty,domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},ip_ranges:{items:[{module_id:"m",entry:{id:"r",prefix:"192.0.2.0/24",community_id:"c"}}]},asns:{items:[{...asn,entry:{...asn.entry,prefix_count:5}}]}}
assert.equal(aggregateCommunityRouteCounts(parseEvoCatalogRaw(raw)).get("c"),7)
assert.throws(()=>aggregateCommunityRouteCounts(parseEvoCatalogRaw({...raw,asns:{items:[{...asn,entry:{...asn.entry,prefix_count:Number.MAX_SAFE_INTEGER}}]}})), /вне допустимого диапазона/)
})
+42
View File
@@ -0,0 +1,42 @@
import { z } from "zod"
const id = z.string().trim().min(1)
const count = z.number().int().nonnegative().safe()
const communityId = id.nullish()
function items<T extends z.ZodType>(row: T, key: (value: z.output<T>) => string) {
return z.object({ items: z.array(row).refine(rows => new Set(rows.map(key)).size === rows.length) })
}
const schema = z.object({
modules: items(z.object({ id, name: z.string(), type: z.string() }), row => row.id),
domains: items(z.object({ module_id: id, entry: z.object({ id, fqdn: id, community_id: communityId }) }), row => `${row.module_id}:${row.entry.id}`),
ip_ranges: items(z.object({ module_id: id, entry: z.object({ id, prefix: id, community_id: id }) }), row => `${row.module_id}:${row.entry.id}`),
asns: items(z.object({ module_id: id, entry: z.object({
id, asn: z.number().int().min(1).max(4294967295), community_id: communityId,
asn_name: z.string().nullish(), prefix_count: count.nullish(),
asn_resolved_at: z.string().nullish(),
}) }), row => `${row.module_id}:${row.entry.id}`),
communities: items(z.object({ id, community: z.string().optional(), title: z.string().optional() }), row => row.id),
})
export type EvoCatalogRaw = z.output<typeof schema>
export function parseEvoCatalogRaw(raw: unknown): EvoCatalogRaw {
const result = schema.safeParse(raw)
if (!result.success) throw new Error("Каталог Evolution не подтверждён: получен некорректный ответ интеграции")
return result.data
}
/** Домен/CIDR дают один маршрут; неизвестный prefix_count ASN делает итог community неизвестным. */
export function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map<string, number | null> {
const totals = new Map<string, number | null>()
function add(community: string, count: number | null) {
const previous = totals.has(community) ? totals.get(community)! : 0
if (previous === null || count === null) { totals.set(community, null); return }
const total = previous + count
if (!Number.isSafeInteger(total)) throw new Error("Каталог Evolution не подтверждён: итог маршрутов вне допустимого диапазона")
totals.set(community, total)
}
for (const { entry } of catalog.domains.items) if (entry.community_id) add(entry.community_id, 1)
for (const { entry } of catalog.ip_ranges.items) add(entry.community_id, 1)
for (const { entry } of catalog.asns.items) if (entry.community_id) add(entry.community_id, entry.prefix_count ?? null)
return totals
}