From 7e3cb754b0d4e4d256b4388a1150d198c2e66dcb Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 3 Oct 2026 13:51:07 +0700 Subject: [PATCH] =?UTF-8?q?fix(evolution):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D0=BE=D0=B3=20=D0=B8=20=D1=81=D0=BE=D1=85=D1=80=D0=B0=D0=BD?= =?UTF-8?q?=D1=8F=D1=82=D1=8C=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B5=20=D1=81=D1=87=D1=91=D1=82=D1=87=D0=B8?= =?UTF-8?q?=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(main)/asns/page.tsx | 2 +- app/(main)/communities/page.tsx | 2 +- backend/package.json | 2 +- backend/src/routes/evobgp.ts | 80 +++---------------- backend/src/services/evobgp-catalog.test.ts | 32 ++++++++ backend/src/services/evobgp-catalog.ts | 42 ++++++++++ components/data-grids/asns-data-grid.tsx | 2 +- .../data-grids/communities-data-grid.tsx | 4 +- docs/ux-ui-plan.md | 2 + docs/ux-ui-progress.md | 9 ++- docs/ux-ui-stage-11b.md | 2 + lib/data.ts | 2 +- lib/evobgp-catalog.test.ts | 14 ++++ lib/evobgp-catalog.ts | 4 +- lib/evobgp-context.tsx | 2 +- tests/e2e/evobgp-counts.spec.ts | 21 +++++ 16 files changed, 140 insertions(+), 82 deletions(-) create mode 100644 backend/src/services/evobgp-catalog.test.ts create mode 100644 backend/src/services/evobgp-catalog.ts create mode 100644 tests/e2e/evobgp-counts.spec.ts diff --git a/app/(main)/asns/page.tsx b/app/(main)/asns/page.tsx index cc5230df..74521dbc 100644 --- a/app/(main)/asns/page.tsx +++ b/app/(main)/asns/page.tsx @@ -35,7 +35,7 @@ export default function AsnsPage() { (r) => r.asn.toLowerCase().includes(q) || r.org.toLowerCase().includes(q) || - String(r.prefixes).includes(q), + (r.prefixes === null ? "" : String(r.prefixes)).includes(q), ) }, [rows, search]) diff --git a/app/(main)/communities/page.tsx b/app/(main)/communities/page.tsx index b7417af6..a6d9b05d 100644 --- a/app/(main)/communities/page.tsx +++ b/app/(main)/communities/page.tsx @@ -256,7 +256,7 @@ export default function CommunitiesPage() { {[ ["Тип", TYPE_LABELS[selected.type]], ["Действие", `${ACTION_LABELS[selected.action]}${selected.actionValue !== undefined ? ` ${selected.actionValue}` : ""}`], - ["Маршрутов", selected.prefixCount.toLocaleString("ru-RU")], + ["Маршрутов", selected.prefixCount?.toLocaleString("ru-RU") ?? "—"], ["Серверов", selected.serverCount.toLocaleString("ru-RU")], ].map(([k, v]) => (
diff --git a/backend/package.json b/backend/package.json index 4deff10f..6ce1fe9b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -28,7 +28,7 @@ "test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts", "test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts", "test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution", - "test:evolution": "tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", + "test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts", "test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts", "test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts", diff --git a/backend/src/routes/evobgp.ts b/backend/src/routes/evobgp.ts index b36d695e..b0bf6ceb 100644 --- a/backend/src/routes/evobgp.ts +++ b/backend/src/routes/evobgp.ts @@ -1,6 +1,7 @@ import { eq, sql } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp" +import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js" import { createEvoBgpSettingsRoutes } from "./evobgp-settings.js" import { db } from "../db/index.js" import { evobgpSettings, filterRules } from "../db/schema.js" @@ -14,67 +15,6 @@ async function ensureEvobgpRow() { return row! } -interface EvoCatalogRaw { - modules: { items: Array<{ id: string; name: string; type: string }> } - domains: { - items: Array<{ - module_id: string - entry: { id: string; fqdn: string; community_id?: string | null } - }> - } - asns: { - items: Array<{ - module_id: string - entry: { - id: string - asn: number - community_id?: string | null - asn_name?: string | null - prefix_count?: number | null - asn_resolved_at?: string | null - } - }> - } - ip_ranges: { - items: Array<{ - module_id: string - entry: { id: string; prefix: string; community_id: string } - }> - } - communities: { - items: Array<{ id: string; community?: string; title?: string }> - } -} - -/** - * По UUID community в каталоге EvoBGP: сумма «маршрутов» — - * 1 на домен, 1 на CIDR в ip_ranges, prefix_count на ASN. - */ -function aggregateCommunityRouteCounts(catalog: EvoCatalogRaw): Map { - const routesPerComm = new Map() - function bump(commId: string, routeAdd: number) { - routesPerComm.set(commId, (routesPerComm.get(commId) ?? 0) + routeAdd) - } - - for (const rowItem of catalog.domains?.items ?? []) { - const cid = rowItem.entry.community_id - if (cid) bump(cid, 1) - } - for (const rowItem of catalog.ip_ranges?.items ?? []) { - const cid = rowItem.entry.community_id - if (cid) bump(cid, 1) - } - for (const rowItem of catalog.asns?.items ?? []) { - const cid = rowItem.entry.community_id - if (!cid) continue - const pc = rowItem.entry.prefix_count - const n = typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0 - bump(cid, n) - } - - return routesPerComm -} - /** * Уникальные серверы из локальной БД, у которых в BGP-фильтре указан community (строка AS:NNN). * Ключ — значение `filter_rules.community` после trim (как в EvoBGP `community`). @@ -168,10 +108,10 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" }) } try { - const catalog = await fetchEvoJson(cred.root, "/v1/router-lists/catalog", cred.apiKey) - const modules = catalog.modules?.items ?? [] + const catalog = parseEvoCatalogRaw(await fetchEvoJson(cred.root, "/v1/router-lists/catalog", cred.apiKey)) + const modules = catalog.modules.items const modMap = new Map(modules.map((m) => [m.id, m])) - const commItems = catalog.communities?.items ?? [] + const commItems = catalog.communities.items const commLabel = new Map() for (const c of commItems) { const label = (c.community && c.title) @@ -183,7 +123,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { const fmtTime = new Date().toLocaleString("ru-RU") const fetchedAt = new Date().toISOString() - const domains = (catalog.domains?.items ?? []).map((rowItem) => { + const domains = catalog.domains.items.map((rowItem) => { const mod = modMap.get(rowItem.module_id) const cid = rowItem.entry.community_id return { @@ -198,7 +138,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { } }) - const ipRanges = (catalog.ip_ranges?.items ?? []).map((rowItem) => { + const ipRanges = catalog.ip_ranges.items.map((rowItem) => { const mod = modMap.get(rowItem.module_id) const cid = rowItem.entry.community_id return { @@ -213,13 +153,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { } }) - const asns = (catalog.asns?.items ?? []).map((rowItem) => { + const asns = catalog.asns.items.map((rowItem) => { const cid = rowItem.entry.community_id const e = rowItem.entry const name = (e.asn_name ?? "").trim() - const pc = e.prefix_count - const prefixes = - typeof pc === "number" && Number.isFinite(pc) && pc >= 0 ? Math.trunc(pc) : 0 + const prefixes = e.prefix_count ?? null let updated = fmtTime const rawResolved = (e.asn_resolved_at ?? "").trim() if (rawResolved) { @@ -251,7 +189,7 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { type: "custom" as const, filterIds: [] as string[], serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0, - prefixCount: routeCountsByCommUuid.get(c.id) ?? 0, + prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0, action: "permit" as const, enabled: true, } diff --git a/backend/src/services/evobgp-catalog.test.ts b/backend/src/services/evobgp-catalog.test.ts new file mode 100644 index 00000000..980ccf97 --- /dev/null +++ b/backend/src/services/evobgp-catalog.test.ts @@ -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}}]}})), /вне допустимого диапазона/) +}) diff --git a/backend/src/services/evobgp-catalog.ts b/backend/src/services/evobgp-catalog.ts new file mode 100644 index 00000000..1951aba0 --- /dev/null +++ b/backend/src/services/evobgp-catalog.ts @@ -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(row: T, key: (value: z.output) => 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 + +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 { + const totals = new Map() + 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 +} diff --git a/components/data-grids/asns-data-grid.tsx b/components/data-grids/asns-data-grid.tsx index 10236842..51f481b0 100644 --- a/components/data-grids/asns-data-grid.tsx +++ b/components/data-grids/asns-data-grid.tsx @@ -58,7 +58,7 @@ function AsnsDataGrid({ asns, isLoading, pagination = false }: AsnsDataGridProps accessorKey: "prefixes", header: ({ column }) => , cell: ({ row }) => ( - {row.original.prefixes.toLocaleString("ru")} + {row.original.prefixes?.toLocaleString("ru") ?? "—"} ), meta: { headerTitle: "Префиксов", diff --git a/components/data-grids/communities-data-grid.tsx b/components/data-grids/communities-data-grid.tsx index 29d43587..0db97a61 100644 --- a/components/data-grids/communities-data-grid.tsx +++ b/components/data-grids/communities-data-grid.tsx @@ -34,7 +34,7 @@ export interface CommunityRow { type: CommunityType filterIds: string[] serverCount: number - prefixCount: number + prefixCount: number | null action: "permit" | "deny" | "local-pref" | "metric" actionValue?: number enabled: boolean @@ -177,7 +177,7 @@ function CommunitiesDataGrid({ ), cell: ({ row }) => ( - {row.original.prefixCount.toLocaleString("ru-RU")} + {row.original.prefixCount?.toLocaleString("ru-RU") ?? "—"} ), meta: { diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index ed1edd5a..5b3ba2fa 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -276,3 +276,5 @@ REST запрос → ожидание → подтверждённый output / Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation. Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers. + +Завершён 11Б.3.5.3.2: strict upstream catalog, nullable ASN/community counts, unknown «—» вместо 0. Parser/aggregation/HTTP/JWT/build/typecheck/lint и provider/counts E2E прошли; locator inline details исправлен. Реальный Evolution/PG не принят. Далее 11Б.3.5.4.1 — форма Evolution dirty/rights/lock/key clear/source. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 33036c1e..f95607ce 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -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.5.3.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.3.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,13 @@ +## Блок 11Б.3.5.3.2 — достоверность каталога Evolution (2026-10-03) + +- Backend проверяет все пять upstream collections/items, обязательные поля, уникальность идентификаторов, диапазон ASN и целые безопасные counts до mapping. Malformed больше не превращается в подтверждённый пустой каталог; корректные пустые массивы допустимы. Неизвестный prefix_count (нет поля/null) сохраняется как null; итог community с таким ASN тоже unknown, известный ноль остаётся 0. Переполнение суммы отклоняется. +- Nullable counts проведены через frontend parser/types, ReUI ASN/communities grids, поиск и детали; unknown выводится «—», локальный подтверждённый serverCount остаётся числом. +- 4 backend parser/aggregation tests, существующие Evolution HTTP/JWT, 4 frontend parser tests, backend build, общий typecheck и профильный lint прошли. 8 provider E2E прошли; новый counts E2E проверил ASN/community zero/unknown и детали после уточнения locator (детали — inline panel, не dialog). +- Реальный Evolution/PG не проверен; соответствие optional upstream полей требует внешней приёмки. Форма и live mock fallback ещё не приняты. Следующий блок 11Б.3.5.4.1 — отдельная форма с dirty-only сохранением, правами, lock, подтверждением удаления ключа и source guards. + ## Блок 11Б.3.5.3.1 — Evolution provider и свежесть (2026-10-03) - Provider подключил strict settings/save/test helpers. GET error/malformed не становится settingsLoaded; settings и catalog имеют независимые loading/error/browser receivedAt. Catalog failure сохраняет последнее snapshot/time; отключение/смена сохранённого URL/secret flag и запись ключа инвалидируют прежний каталог. Save заканчивается по receipt до независимого запроса каталога. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index cedb63c3..6f0fee1d 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -204,3 +204,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Завершён 11Б.3.5.2: Evolution backend selective RETURNING save/public receipt и проверка items upstream/test; DI HTTP/JWT, backend build/lint прошли. Старый provider совместим с публичными полями, ещё не мигрирован. Далее 11Б.3.5.3 — provider known/error/time/source; затем форма и upstream catalog validation. Завершён 11Б.3.5.3.1: Evolution provider known/error/time/readers/source/strict receipts, статус Settings; 8 provider (один selector повторён), 2 прежних и 6 source E2E, lint/typecheck/production build прошли. Форма/consumer mocks ещё не приняты. Далее 11Б.3.5.3.2 — upstream catalog strict/unknown counts; затем 11Б.3.5.4 форма/consumers. + +Завершён 11Б.3.5.3.2: strict upstream catalog, nullable ASN/community counts, unknown «—» вместо 0. Parser/aggregation/HTTP/JWT/build/typecheck/lint и provider/counts E2E прошли; locator inline details исправлен. Реальный Evolution/PG не принят. Далее 11Б.3.5.4.1 — форма Evolution dirty/rights/lock/key clear/source. diff --git a/lib/data.ts b/lib/data.ts index ba5b051a..b39150ca 100644 --- a/lib/data.ts +++ b/lib/data.ts @@ -134,7 +134,7 @@ export interface Asn { asn: string org: string country: string - prefixes: number + prefixes: number | null filter: string updated: string enabled: boolean diff --git a/lib/evobgp-catalog.test.ts b/lib/evobgp-catalog.test.ts index d04d61c4..f62c09c5 100644 --- a/lib/evobgp-catalog.test.ts +++ b/lib/evobgp-catalog.test.ts @@ -15,3 +15,17 @@ test("ASN and community zero counts are preserved, invalid counts rejected", () assert.equal(parseEvoBgpCatalog({ ...empty, asns: [asn] }).asns[0].prefixes, 0) assert.throws(() => parseEvoBgpCatalog({ ...empty, asns: [{ ...asn, prefixes: -1 }] })) }) + + +test("unknown counts stay nullable and differ from confirmed zero", () => { + const asn = { id: "asn-unknown", asn: "AS64512", org: "", country: "", prefixes: null, filter: "", updated: "—", enabled: true } + const community = { id: "c", value: "64512:1", name: "c", description: "", type: "custom", filterIds: [], serverCount: 0, prefixCount: null, action: "permit", enabled: true } + const result = parseEvoBgpCatalog({...empty, asns:[asn], communities:[community]}) + assert.equal(result.asns[0].prefixes,null) + assert.equal(result.communities[0].prefixCount,null) + assert.equal(result.communities[0].serverCount,0) + for(const value of [undefined,-1,1.5,Number.MAX_SAFE_INTEGER+1]) { + assert.throws(()=>parseEvoBgpCatalog({...empty,asns:[{...asn,prefixes:value}]})) + assert.throws(()=>parseEvoBgpCatalog({...empty,communities:[{...community,prefixCount:value}]})) + } +}) diff --git a/lib/evobgp-catalog.ts b/lib/evobgp-catalog.ts index d1e6063c..561c4ca5 100644 --- a/lib/evobgp-catalog.ts +++ b/lib/evobgp-catalog.ts @@ -12,8 +12,8 @@ const schema = z.object({ modules: unique(z.object({ id, name: text, type: text })), domains: unique(z.object({ ...base, domain: text, resolvedIp: text })), ipRanges: unique(z.object({ ...base, cidr: text, country: text })), - asns: unique(z.object({ id, asn: text, org: text, country: text, prefixes: count, filter: text, updated: text, enabled: z.boolean() })), - communities: unique(z.object({ id, value: text, name: text, description: text, type: z.enum(["standard", "no-export", "no-advertise", "local-as", "custom"]), filterIds: z.array(text), serverCount: count, prefixCount: count, action: z.enum(["permit", "deny", "local-pref", "metric"]), actionValue: z.number().finite().optional(), enabled: z.boolean() })), + asns: unique(z.object({ id, asn: text, org: text, country: text, prefixes: count.nullable(), filter: text, updated: text, enabled: z.boolean() })), + communities: unique(z.object({ id, value: text, name: text, description: text, type: z.enum(["standard", "no-export", "no-advertise", "local-as", "custom"]), filterIds: z.array(text), serverCount: count, prefixCount: count.nullable(), action: z.enum(["permit", "deny", "local-pref", "metric"]), actionValue: z.number().finite().optional(), enabled: z.boolean() })), }) export function parseEvoBgpCatalog(raw: unknown): EvoBgpCatalogSnapshot { const parsed = schema.safeParse(raw) diff --git a/lib/evobgp-context.tsx b/lib/evobgp-context.tsx index 18c3c97c..e66eb881 100644 --- a/lib/evobgp-context.tsx +++ b/lib/evobgp-context.tsx @@ -16,7 +16,7 @@ export interface EvoBgpCommunityRow { type: "standard" | "no-export" | "no-advertise" | "local-as" | "custom" filterIds: string[] serverCount: number - prefixCount: number + prefixCount: number | null action: "permit" | "deny" | "local-pref" | "metric" actionValue?: number enabled: boolean diff --git a/tests/e2e/evobgp-counts.spec.ts b/tests/e2e/evobgp-counts.spec.ts new file mode 100644 index 00000000..57b49f27 --- /dev/null +++ b/tests/e2e/evobgp-counts.spec.ts @@ -0,0 +1,21 @@ +import {test,expect} from "@playwright/test" +import {evoSettings,evoCatalog} from "./fixtures/evobgp" + +test("Evolution: unknown prefix counts are distinct from confirmed zero in grids and details",async({page})=>{ + const asn={id:"a",asn:"AS64512",org:"Unknown prefixes",country:"—",prefixes:null,filter:"c",updated:"сейчас",enabled:true} + const community={id:"c",value:"64512:1",name:"Unknown routes",description:"",type:"custom",filterIds:[],serverCount:0,prefixCount:null,action:"permit",enabled:true} + const catalog={...evoCatalog,asns:[asn,{...asn,id:"zero",asn:"AS64513",org:"Zero prefixes",prefixes:0}],communities:[community,{...community,id:"zero",value:"64512:2",name:"Zero routes",prefixCount:0}]} + await page.route("**/health",r=>r.fulfill({json:{ok:true}})) + await page.route("**/api/**",r=>{const path=new URL(r.request().url()).pathname;return r.fulfill(path==="/api/auth/config"?{json:{required:false}}:path==="/api/evobgp/settings"?{json:evoSettings}:path==="/api/evobgp/catalog"?{json:catalog}:{status:503,json:{error:"outside fixture"}})}) + await page.goto("/asns") + const unknown=page.getByRole("row").filter({hasText:"AS64512"}),zero=page.getByRole("row").filter({hasText:"AS64513"}) + await expect(unknown.getByRole("cell").nth(2)).toHaveText("—") + await expect(zero.getByRole("cell").nth(2)).toHaveText("0") + await page.locator('a[href="/communities"]').first().click() + const unknownCommunity=page.getByRole("row").filter({hasText:"Unknown routes"}),zeroCommunity=page.getByRole("row").filter({hasText:"Zero routes"}) + await expect(unknownCommunity).toBeVisible() + await expect(unknownCommunity.getByRole("cell").filter({hasText:/^—$/})).toHaveCount(1) + await expect(zeroCommunity.getByRole("cell").filter({hasText:/^0$/})).toHaveCount(2) + await unknownCommunity.click() + await expect(page.getByText("Маршрутов",{exact:true}).locator("..").getByText("—",{exact:true})).toBeVisible() +})