diff --git a/backend/package.json b/backend/package.json index da99e140..d426c4bb 100644 --- a/backend/package.json +++ b/backend/package.json @@ -19,11 +19,12 @@ "test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-ifindex.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-dest.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts && tsx src/services/traffic-flow-geoip.test.ts && tsx src/services/traffic-flow-facts.test.ts && tsx src/services/traffic-flow-facts-filter.test.ts && tsx src/services/traffic-flow-facts-rebuild.test.ts && tsx src/services/statistics-aggregate.test.ts", "test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts", "test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/traffic-flags.test.ts && tsx src/db/pg-schema.test.ts && tsx src/services/config-revisions.test.ts", + "test:recursive-routes": "tsx src/routes/recursive-routes.test.ts", "test:config-sync": "tsx src/services/config-apply-plan.test.ts && tsx src/services/entity-snapshots.test.ts", "test:ros-file": "tsx src/services/ros-file-contents.test.ts", "test:backups": "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: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:live-maps && npm run test:config-sync && npm run test:ros-file", + "test": "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:live-maps && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file", "test:geoip": "tsx src/services/traffic-flow-geoip.test.ts" }, "dependencies": { diff --git a/backend/src/routes/recursive-routes.test.ts b/backend/src/routes/recursive-routes.test.ts new file mode 100644 index 00000000..14327205 --- /dev/null +++ b/backend/src/routes/recursive-routes.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod" +import { createRecursiveRoutes, type RecursiveRouteDependencies } from "./recursive-routes.js" +const row = { id: "one", dstAddress: "10.0.0.0/24", gateway: "192.0.2.1", distance: 0, scope: 0, targetScope: 0, routingTable: "main", checkGateway: "ping", country: "", comment: "Маршрут", disabled: false } +const server = { id: 1, name: "Тест" } as NonNullable>> +let lookup = 0, writes = 0, revisions = 0, fail = false, stored = [row], payload: unknown = [row], revisionServer = 1, section = "recursive-routes" +const applied: Array<{ rows: typeof row[]; source: string }> = [] +const app = Fastify() +app.setValidatorCompiler(validatorCompiler); app.setSerializerCompiler(serializerCompiler) +await app.register(createRecursiveRoutes({ + getServer: async id => { lookup++; return id === 1 ? server : undefined }, + getRoutes: async () => stored, + apply: async (device, rows, source) => { + assert.equal(device.id, 1); writes++; applied.push({ rows: rows as typeof row[], source }) + if (fail) throw new Error("Тест частичного применения") + stored = rows as typeof row[]; return { pushed: rows.length, deleted: 0 } + }, + getRevision: async () => { + revisions++ + return { id: "rev", serverId: revisionServer, section, payload } as NonNullable>> + }, +}), { prefix: "/api" }) +const invalid = [ + {}, { serverId: 1 }, { serverId: 1, routes: null }, { serverId: 1, routes: {} }, + { serverId: null, routes: [] }, { serverId: "srv1", routes: [] }, { serverId: "9007199254740993", routes: [] }, + { serverId: 1, routes: [{}] }, { serverId: 1, routes: [{ ...row, disabled: "false" }] }, + { serverId: 1, routes: [{ ...row, distance: -1 }] }, { serverId: 1, routes: [{ ...row, scope: 256 }] }, + { serverId: 1, routes: [{ ...row, targetScope: 1.5 }] }, { serverId: 1, routes: [{ ...row, gateway: "" }] }, + { serverId: 1, routes: [row, row] }, { serverId: 1, routes: [row], source: "rollback" }, +] +for (const body of invalid) { + const result = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: body }) + assert.equal(result.statusCode, 400, JSON.stringify(body)) + assert.equal(lookup, 0); assert.equal(writes, 0) +} +const empty = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: "1", routes: [] } }) +assert.equal(empty.statusCode, 200); assert.deepEqual(empty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 }) +const known = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: 1, routes: [row], source: "copy" } }) +assert.equal(known.statusCode, 200); assert.equal(known.json().routes[0].distance, 0); assert.equal(known.json().routes[0].scope, 0) +assert.equal(applied.at(-1)?.source, "copy") +fail = true +const failed = await app.inject({ method: "PUT", url: "/api/recursive-routes", payload: { serverId: 1, routes: [row] } }) +assert.equal(failed.statusCode, 500); assert.equal(failed.json().ok, undefined); fail = false +for (const body of [{}, { serverId: 0 }, { serverId: "srv1" }]) { + const before = writes, reads = revisions + const result = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: body }) + assert.equal(result.statusCode, 400); assert.equal(writes, before); assert.equal(revisions, reads) +} +revisionServer = 2 +const before = writes +assert.equal((await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })).statusCode, 400) +assert.equal(writes, before); revisionServer = 1 +section = "filters" +assert.equal((await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } })).statusCode, 400) +assert.equal(writes, before); section = "recursive-routes" +for (payload of [null, {}, [{}], [{ ...row, disabled: "false" }], [{ ...row, distance: null }]]) { + const result = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } }) + assert.equal(result.statusCode, 409); assert.equal(writes, before) +} +payload = [{ ...row, id: undefined }] +const restored = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } }) +assert.equal(restored.statusCode, 200); assert.equal(restored.json().serverId, 1); assert.equal(applied.at(-1)?.source, "rollback") +assert.equal(restored.json().routes[0].distance, 0) +payload = [] +const restoredEmpty = await app.inject({ method: "POST", url: "/api/recursive-routes/revisions/rev/restore", payload: { serverId: 1 } }) +assert.deepEqual(restoredEmpty.json(), { ok: true, serverId: 1, routes: [], pushedRoutes: 0 }) +await app.close() +console.log("recursive routes HTTP: invalid/explicit-empty/zero/copy/error/restore ownership and payload passed without DB/RouterOS") + diff --git a/backend/src/routes/recursive-routes.ts b/backend/src/routes/recursive-routes.ts index 4ed5e7cb..5a0c185f 100644 --- a/backend/src/routes/recursive-routes.ts +++ b/backend/src/routes/recursive-routes.ts @@ -1,3 +1,4 @@ +import { recursiveApplyBodySchema, recursiveTargetSchema, recursiveInputRowsSchema } from '../services/recursive-route-validation.js' import { asc, eq } from "drizzle-orm" import { z } from "zod" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" @@ -172,12 +173,26 @@ async function applyRecursiveToServer( const RevisionIdParamSchema = z.object({ id: z.string().min(1) }) -const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { +export interface RecursiveRouteDependencies { + getServer: (id: number) => Promise + getRoutes: typeof mapDbRoutes + apply: typeof applyRecursiveToServer + getRevision: typeof getRevisionById +} + +export function createRecursiveRoutes(overrides: Partial = {}): FastifyPluginAsyncZod { + const deps: RecursiveRouteDependencies = { + getServer: async id => (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0], + getRoutes: mapDbRoutes, apply: applyRecursiveToServer, getRevision: getRevisionById, + ...overrides, + } + return async (app) => { + app.get("/recursive-routes/gateways", async (req, reply) => { const q = req.query as { serverId?: string | number } const serverId = parseDbServerId(q.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) - const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] + const server = await deps.getServer(serverId) if (!server) return reply.status(404).send({ error: "Server not found" }) try { @@ -207,13 +222,10 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { const q = req.query as { serverId?: string | number } const serverId = parseDbServerId(q.serverId) if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) - const server: ServerRow | undefined = (await db - .select().from(servers) - .where(eq(servers.id, serverId)) - .limit(1))[0] + const server = await deps.getServer(serverId) if (!server) return reply.status(404).send({ error: "Server not found" }) - const cached = await mapDbRoutes(serverId) + const cached = await deps.getRoutes(serverId) try { const client = MikrotikClient.fromServer(server) const rosRoutes = await client.get("/ip/route") @@ -225,7 +237,7 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { source: "observed", payload: canonicalRecursiveRoutes(live), }) - const stored = await mapDbRoutes(serverId) + const stored = await deps.getRoutes(serverId) return reply.send({ serverId, routes: stored, live: true, stale: false }) } catch (err) { app.log.warn({ serverId, err: String(err) }, "recursive live GET failed, serving cache") @@ -240,23 +252,16 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { }) app.put("/recursive-routes", async (req, reply) => { - const body = req.body as { - serverId?: string | number - routes?: RecursiveRouteDto[] - source?: ConfigRevisionSource - } - const serverId = parseDbServerId(body.serverId) - if (serverId === null) return reply.status(400).send({ error: "serverId is required" }) - const server = (await db.select().from(servers).where(eq(servers.id, serverId)).limit(1))[0] + const parsed = recursiveApplyBodySchema.safeParse(req.body) + if (!parsed.success) return reply.status(400).send({ error: "Некорректный запрос маршрутов: нужны serverId и полный список routes" }) + const body = parsed.data, serverId = body.serverId + const server = await deps.getServer(serverId) if (!server) return reply.status(404).send({ error: "Server not found" }) - const routes = (body.routes ?? []).map((r) => ({ - ...r, - comment: userRecursiveComment(r.comment), - })) + const routes = body.routes.map(r => ({ ...r, comment: userRecursiveComment(r.comment) })) const source: ConfigRevisionSource = body.source === "copy" ? "copy" : "apply" try { - const result = await applyRecursiveToServer(server, routes, source) - const stored = await mapDbRoutes(serverId) + const result = await deps.apply(server, routes, source) + const stored = await deps.getRoutes(serverId) return reply.send({ ok: true, serverId, routes: stored, pushedRoutes: result.pushed }) } catch (err) { return reply.status(500).send({ error: String(err) }) @@ -275,40 +280,22 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { schema: { params: RevisionIdParamSchema }, }, async (req, reply) => { const { id } = req.params - const body = req.body as { serverId?: string | number } | undefined - const rev = await getRevisionById(id) + const parsedTarget = recursiveTargetSchema.safeParse(req.body) + if (!parsedTarget.success) return reply.status(400).send({ error: "Для восстановления нужен serverId" }) + const rev = await deps.getRevision(id) if (!rev) return reply.status(404).send({ error: "Revision not found" }) - if (rev.section !== "recursive-routes") { - return reply.status(400).send({ error: "Revision section mismatch" }) - } - const requested = parseDbServerId(body?.serverId) - if (requested !== null && requested !== rev.serverId) { - return reply.status(400).send({ error: "Revision belongs to another server" }) - } - const server = (await db.select().from(servers).where(eq(servers.id, rev.serverId)).limit(1))[0] + if (rev.section !== "recursive-routes") return reply.status(400).send({ error: "Revision section mismatch" }) + if (parsedTarget.data.serverId !== rev.serverId) return reply.status(400).send({ error: "Revision belongs to another server" }) + const parsedRoutes = recursiveInputRowsSchema.safeParse(rev.payload) + if (!parsedRoutes.success) return reply.status(409).send({ error: "Версия содержит некорректные маршруты; восстановление не выполнено" }) + const server = await deps.getServer(rev.serverId) if (!server) return reply.status(404).send({ error: "Server not found" }) - - const raw = Array.isArray(rev.payload) ? rev.payload : [] - const routes: RecursiveRouteDto[] = raw.map((item, idx) => { - const r = item as Partial - return { - id: `rev-${idx}`, - dstAddress: r.dstAddress ?? "", - gateway: r.gateway ?? "", - distance: r.distance ?? 1, - scope: r.scope ?? null, - targetScope: r.targetScope ?? null, - routingTable: r.routingTable || "main", - checkGateway: r.checkGateway ?? "", - country: r.country ?? "", - comment: userRecursiveComment(r.comment), - disabled: Boolean(r.disabled), - } - }) - + const routes: RecursiveRouteDto[] = parsedRoutes.data.map((row, index) => ({ + ...row, id: `rev-${index}`, comment: userRecursiveComment(row.comment), + })) try { - const result = await applyRecursiveToServer(server, routes, "rollback") - const stored = await mapDbRoutes(server.id) + const result = await deps.apply(server, routes, "rollback") + const stored = await deps.getRoutes(server.id) return reply.send({ ok: true, serverId: server.id, routes: stored, pushedRoutes: result.pushed }) } catch (err) { return reply.status(500).send({ error: String(err) }) @@ -316,4 +303,6 @@ const recursiveRoutesPlugin: FastifyPluginAsyncZod = async (app) => { }) } -export default recursiveRoutesPlugin +} + +export default createRecursiveRoutes() diff --git a/backend/src/services/recursive-route-validation.ts b/backend/src/services/recursive-route-validation.ts new file mode 100644 index 00000000..eaa47e4b --- /dev/null +++ b/backend/src/services/recursive-route-validation.ts @@ -0,0 +1,18 @@ +import { z } from "zod" +const serverId = z.union([z.number().int().positive(), z.string().trim().regex(/^\d+$/)]) + .transform(Number).refine(value => Number.isSafeInteger(value) && value > 0) +export const recursiveTargetSchema = z.object({ serverId }) +export const recursiveInputRowSchema = z.object({ + id: z.string().optional().default(""), + dstAddress: z.string().trim().min(1), gateway: z.string().trim().min(1), + distance: z.number().int().min(0).max(255), + scope: z.number().int().min(0).max(255).nullable(), + targetScope: z.number().int().min(0).max(255).nullable(), + routingTable: z.string().trim().min(1), checkGateway: z.string(), + country: z.string(), comment: z.string(), disabled: z.boolean(), +}) +export const recursiveInputRowsSchema = z.array(recursiveInputRowSchema) + .refine(rows => { const ids = rows.map(row => row.id).filter(Boolean); return new Set(ids).size === ids.length }) +export const recursiveApplyBodySchema = recursiveTargetSchema.extend({ + routes: recursiveInputRowsSchema, source: z.enum(["apply", "copy"]).optional(), +}) diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 8fd4051e..5fb4bdd5 100644 --- a/docs/ux-ui-progress.md +++ b/docs/ux-ui-progress.md @@ -26,11 +26,19 @@ | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже | | 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 | | 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 | -| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.24; операции рекурсивных маршрутов, далее backend validation | +| 10 | Маршрутизация, диагностика, карта и терминал | В работе: 10.1–10.25; операции/валидация рекурсивных маршрутов, далее URL/source | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | +## Блок 10.25 — backend-валидация рекурсивных операций (2026-09-30) + +- PUT требует явные serverId/routes и валидные параметры каждого маршрута; отсутствие routes не превращается в удаление всех managed-маршрутов. Пустой массив остаётся допустимым намеренным действием. Нули distance/scope/targetScope сохраняются. +- Restore требует точного serverId, совпадения раздела/устройства и валидного payload. Null/object/неполный snapshot не подменяется пустым списком или default параметрами. Canonical версии без id совместимы; id формируется отдельно. +- Фабрика зависимостей позволяет через HTTP injection проверить отказ до поиска устройства и сетевого apply. Реальные БД/RouterOS не вызываются. +- Проверки: backend HTTP regression (15 malformed PUT, explicit empty/zero/copy/error, restore ownership/payload), auth smoke/permissions, config-sync, build и lint прошли. Алгоритм add/delete, managed markers и source apply/copy/rollback прежние. +- Далее 10.26: URL устройства/поиска/группы и source/history при незавершённой операции; затем strict observed parser и визуальная приёмка. Последняя квота: 38% окна, 37% недели использовано. Локальный коммит без push. + ## Блок 10.24 — подтверждённые операции рекурсивных маршрутов (2026-09-30) - PUT и restore проверяют ok, serverId, полный список и pushedRoutes; backend добавляет serverId к чтению, шлюзам и receipts. Старые поля сохранены. Новый frontend требует совместимый обновлённый backend; старый ответ безопасно блокирует запись. diff --git a/docs/ux-ui-stage-10.md b/docs/ux-ui-stage-10.md index a4c513f4..39c86b67 100644 --- a/docs/ux-ui-stage-10.md +++ b/docs/ux-ui-stage-10.md @@ -148,3 +148,7 @@ Route subtype и BFD multihop неизвестны без наблюдения. ## 10.24. Операции рекурсивных маршрутов Strict receipts/serverId, общая блокировка save/delete/restore, форма и последний снимок при ошибке; разные routing tables и скрытые поиском endpoints сохранены. 35 E2E, TS/lint, обе сборки и config-sync прошли; 11 общих history повторно прошли после разделения доступности read/write. Далее backend validation, URL/source и observed metadata/layout. Legacy parser пока не мигрирован; сетевые правила применения прежние. + +## 10.25. Валидация PUT/restore + +Явный список и точный serverId обязательны; повреждённая версия не превращается в empty/default конфигурацию. HTTP injection проверяет отказ до сети; auth/config-sync/build/lint прошли. Intentional empty и canonical snapshot без id совместимы; алгоритм применения прежний. Далее URL/source, observed parser и layout.