diff --git a/backend/package.json b/backend/package.json index 69134d61..4deff10f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,7 +27,8 @@ "test:scheduler": "tsx src/services/scheduler-recorded-run.test.ts && tsx src/routes/scheduler.test.ts", "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", + "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: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/plugins/auth.evolution-settings.test.ts b/backend/src/plugins/auth.evolution-settings.test.ts new file mode 100644 index 00000000..6998d19e --- /dev/null +++ b/backend/src/plugins/auth.evolution-settings.test.ts @@ -0,0 +1,15 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {SignJWT} from "jose" +import {createEvoBgpSettingsRoutes} from "../routes/evobgp-settings.js" +process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test" +const {default:authPlugin,requireAuth}=await import("./auth.js") +const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth) +let writes=0,probes=0 +await app.register(createEvoBgpSettingsRoutes({read:async()=>({baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}),write:async()=>{writes++;return {baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}},probe:async()=>{probes++;return {items:[]}}}),{prefix:"/api"}) +for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){ + const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret")) + for(const [method,url] of [["GET","/api/evobgp/settings"],["PUT","/api/evobgp/settings"],["POST","/api/evobgp/test"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:{enabled:false}}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403);assert.equal(response.body.includes("fixture-only"),false)} +} +assert.equal(writes,1);assert.equal(probes,1);assert.equal((await app.inject({method:"GET",url:"/api/evobgp/settings"})).statusCode,401) +await app.close();console.log("Evolution JWT gate: actual settings/test handlers require settings admin, no key echo") diff --git a/backend/src/routes/evobgp-settings.test.ts b/backend/src/routes/evobgp-settings.test.ts new file mode 100644 index 00000000..afd0d9f0 --- /dev/null +++ b/backend/src/routes/evobgp-settings.test.ts @@ -0,0 +1,22 @@ +import assert from "node:assert/strict" +import Fastify from "fastify" +import {createEvoBgpSettingsRoutes} from "./evobgp-settings.js" +let probeValue:unknown={items:[]},probes=0 +let row={baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-existing"},writes=0,fail=false,ignore=false +const app=Fastify();await app.register(createEvoBgpSettingsRoutes({read:async()=>row,probe:async(root,key)=>{probes++;assert.equal(root,row.baseUrl);assert.equal(key,"fixture-test");return probeValue},write:async patch=>{writes++;if(fail)throw Error("DB failure");if(!ignore)row={...row,...patch,apiKey:patch.apiKey===undefined?row.apiKey:patch.apiKey??""};return row}}),{prefix:"/api"}) +const put=(payload:unknown)=>app.inject({method:"PUT",url:"/api/evobgp/settings",payload}) +let response=await app.inject({method:"GET",url:"/api/evobgp/settings"});assert.deepEqual(response.json(),{baseUrl:row.baseUrl,enabled:false,secretConfigured:true});assert.equal(response.body.includes("fixture-existing"),false) +for(const bad of [{},{unexpected:true},{enabled:"false"},{baseUrl:"ftp://evo.test"},{baseUrl:"https://evo.test/v1"},{apiKey:"fixture\ninvalid"}])assert.equal((await put(bad)).statusCode,400) +assert.equal(writes,0) +response=await put({enabled:false});assert.deepEqual(response.json(),{ok:true,baseUrl:row.baseUrl,enabled:false,secretConfigured:true,savedFields:["enabled"]});assert.equal(row.apiKey,"fixture-existing") +response=await put({baseUrl:"Evo.EXAMPLE.test/",apiKey:"Bearer fixture-new"});assert.equal(response.statusCode,200);assert.equal(row.baseUrl,"https://evo.example.test");assert.equal(row.apiKey,"fixture-new");assert.deepEqual(response.json().savedFields,["baseUrl","apiKey"]);assert.equal(response.body.includes("fixture-new"),false) +response=await put({apiKey:null});assert.equal(response.json().secretConfigured,false);assert.equal(row.apiKey,"") +ignore=true;response=await put({apiKey:"fixture-rejected"});assert.equal(response.statusCode,502);assert.equal(response.body.includes("fixture-rejected"),false) +ignore=false;fail=true;response=await put({enabled:true});assert.equal(response.statusCode,500);assert.equal(writes,5) +fail=false;row={...row,apiKey:"fixture-test"} +const probe=()=>app.inject({method:"POST",url:"/api/evobgp/test",payload:{apiKey:""}}) +assert.equal((await probe()).statusCode,200) +for(const raw of [{},null,{items:"bad"},{ok:true}]){probeValue=raw;assert.equal((await probe()).statusCode,502)} +assert.equal(probes,5) +assert.equal((await app.inject({method:"POST",url:"/api/evobgp/test",payload:{baseUrl:"https://evo.test/v1"}})).statusCode,400);assert.equal(probes,5) +await app.close();console.log("Evolution settings HTTP: selective normalized patch, confirmed fields/key without echo, invalid/mismatch/DB error without retry") diff --git a/backend/src/routes/evobgp-settings.ts b/backend/src/routes/evobgp-settings.ts new file mode 100644 index 00000000..b27fdb3b --- /dev/null +++ b/backend/src/routes/evobgp-settings.ts @@ -0,0 +1,34 @@ +import type {FastifyPluginAsyncZod} from "@fastify/type-provider-zod" +import {z} from "zod" +import {evoBgpTestDraftSchema,normalizeEvoBgpBaseUrl,evoBgpSettingsPatchSchema,evoBgpSettingsSchema,normalizeEvoBgpApiKey,type EvoBgpSavePayload} from "@mmapp/contracts/evobgp" +type SettingsRow = {baseUrl:string;enabled:boolean;apiKey:string} +function publicSettings(row:SettingsRow){ + const parsed=evoBgpSettingsSchema.safeParse({baseUrl:row.baseUrl,enabled:row.enabled,secretConfigured:Boolean(normalizeEvoBgpApiKey(row.apiKey))}) + if(!parsed.success)throw new Error("Настройки EvoBGP не подтверждены сервером") + return parsed.data +} +export function createEvoBgpSettingsRoutes(deps:{read:()=>Promise;write:(patch:EvoBgpSavePayload)=>Promise;probe:(root:string,key:string)=>Promise}):FastifyPluginAsyncZod { + return async app=>{ + app.get("/evobgp/settings",async()=>publicSettings(await deps.read())) + app.post("/evobgp/test",async(req,reply)=>{ + const input=evoBgpTestDraftSchema.safeParse(req.body??{}) + if(!input.success)return reply.status(400).send({error:input.error.issues[0]?.message??"Укажите допустимые параметры EvoBGP"}) + try{ + const row=await deps.read(),draft=input.data + const root=normalizeEvoBgpBaseUrl(draft.baseUrl===undefined?row.baseUrl:draft.baseUrl) + const key=normalizeEvoBgpApiKey(draft.apiKey?.trim()?draft.apiKey:row.apiKey) + if(!root||!key)return reply.status(400).send({error:"Нужны URL и API-ключ в форме или сохранённых настройках"}) + const raw=await deps.probe(root,key) + if(!z.object({items:z.array(z.unknown())}).safeParse(raw).success) return reply.status(502).send({error:"Соединение с EvoBGP не подтверждено: некорректный ответ API"}) + return {ok:true} + }catch(error){return reply.status(502).send({error:error instanceof Error?error.message:"Соединение с EvoBGP не подтверждено"})} + }) + app.put("/evobgp/settings",async(req,reply)=>{ + const input=evoBgpSettingsPatchSchema.safeParse(req.body) + if(!input.success)return reply.status(400).send({error:input.error.issues[0]?.message??"Укажите допустимые настройки EvoBGP"}) + const patch=input.data,row=await deps.write(patch) + if(patch.baseUrl!==undefined&&row.baseUrl!==patch.baseUrl||patch.enabled!==undefined&&row.enabled!==patch.enabled||patch.apiKey!==undefined&&row.apiKey!==(patch.apiKey??""))return reply.status(502).send({error:"Сохранение EvoBGP не подтверждено сервером"}) + return {ok:true,...publicSettings(row),savedFields:Object.keys(patch).filter(field=>(patch as Record)[field]!==undefined)} + }) + } +} diff --git a/backend/src/routes/evobgp.ts b/backend/src/routes/evobgp.ts index 63f54865..b36d695e 100644 --- a/backend/src/routes/evobgp.ts +++ b/backend/src/routes/evobgp.ts @@ -1,48 +1,19 @@ import { eq, sql } from "drizzle-orm" import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod" -import { z } from "zod" +import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp" +import { createEvoBgpSettingsRoutes } from "./evobgp-settings.js" import { db } from "../db/index.js" import { evobgpSettings, filterRules } from "../db/schema.js" -const PutEvobgpSchema = z.object({ - baseUrl: z.string().optional(), - enabled: z.boolean().optional(), - /** undefined — не менять; null или "" — очистить ключ; строка — новое значение */ - apiKey: z.union([z.string(), z.null()]).optional(), -}) - -/** Опционально: проверить черновики до сохранения; поле отсутствует — взять из БД */ -const TestEvobgpSchema = z.object({ - baseUrl: z.string().optional(), - apiKey: z.string().optional(), -}) - async function ensureEvobgpRow() { let row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0] if (!row) { - await db.insert(evobgpSettings).values({ id: 1 }) + await db.insert(evobgpSettings).values({ id: 1 }).onConflictDoNothing() row = (await db.select().from(evobgpSettings).where(eq(evobgpSettings.id, 1)).limit(1))[0] } return row! } -function normalizeBaseUrl(raw: string): string { - const trimmed = raw.trim().replace(/\/$/, "") - if (!trimmed) return "" - try { - const u = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`) - return `${u.protocol}//${u.host}` - } catch { - return trimmed - } -} - -/** Сырой API-ключ без префикса Bearer (иначе EvoBGP получит `Bearer Bearer …`). */ -function normalizeApiKey(raw: string): string { - const trimmed = raw.trim() - return trimmed.replace(/^Bearer\s+/i, "").trim() -} - interface EvoCatalogRaw { modules: { items: Array<{ id: string; name: string; type: string }> } domains: { @@ -169,83 +140,20 @@ async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | n } const evobgpRoutes: FastifyPluginAsyncZod = async (app) => { - app.get("/evobgp/settings", async (_req, reply) => { - const row = await ensureEvobgpRow() - return reply.send({ - baseUrl: row.baseUrl ?? "", - enabled: Boolean(row.enabled), - secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")), - }) - }) - - app.put("/evobgp/settings", async (req, reply) => { - const parsed = PutEvobgpSchema.safeParse(req.body) - if (!parsed.success) { - return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) - } - const cur = await ensureEvobgpRow() - let nextBase = cur.baseUrl - let nextEnabled = cur.enabled - let nextKey = cur.apiKey - - if (parsed.data.baseUrl !== undefined) { - nextBase = normalizeBaseUrl(parsed.data.baseUrl) - } - if (parsed.data.enabled !== undefined) nextEnabled = parsed.data.enabled - if (parsed.data.apiKey !== undefined) { - nextKey = - parsed.data.apiKey === null || parsed.data.apiKey === "" - ? "" - : normalizeApiKey(parsed.data.apiKey) - } - - await db.update(evobgpSettings) - .set({ - baseUrl: nextBase, - enabled: nextEnabled, - apiKey: nextKey, - updatedAt: sql`now()`, - }) - .where(eq(evobgpSettings.id, 1)) - - const row = await ensureEvobgpRow() - return reply.send({ - baseUrl: row.baseUrl ?? "", - enabled: Boolean(row.enabled), - secretConfigured: Boolean(normalizeApiKey(row.apiKey ?? "")), - }) - }) - - /** - * Проверка Bearer: тело опционально — можно передать черновики URL/ключа с формы. - * Для каждого поля: если в JSON ключ отсутствует — используется значение из БД; - * если `apiKey` передана пустой строкой — ключ берётся из БД (проверка только нового URL). - */ - app.post("/evobgp/test", async (req, reply) => { - const parsed = TestEvobgpSchema.safeParse(req.body ?? {}) - if (!parsed.success) { - return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() }) - } - const row = await ensureEvobgpRow() - const d = parsed.data - const urlRaw = d.baseUrl !== undefined ? d.baseUrl : row.baseUrl - const keyRaw = - d.apiKey !== undefined && d.apiKey.trim() !== "" ? d.apiKey : row.apiKey - const root = normalizeBaseUrl(urlRaw.trim()) - const token = normalizeApiKey(keyRaw) - if (!root || !token) { - return reply.status(400).send({ - error: "Нужны базовый URL и API-ключ (в форме или уже сохранённые в БД)", - }) - } - try { - await fetchEvoJson<{ items: unknown[] }>(root, "/v1/modules?limit=1", token) - return reply.send({ ok: true }) - } catch (e) { - const msg = e instanceof Error ? e.message : "Ошибка EvoBGP" - return reply.status(502).send({ error: msg }) - } - }) + await app.register(createEvoBgpSettingsRoutes({ + read: ensureEvobgpRow, + probe: (root,key) => fetchEvoJson(root,"/v1/modules?limit=1",key), + write: async patch => { + await ensureEvobgpRow() + const values: {baseUrl?:string;enabled?:boolean;apiKey?:string} = {} + if(patch.baseUrl!==undefined) values.baseUrl=patch.baseUrl + if(patch.enabled!==undefined) values.enabled=patch.enabled + if(patch.apiKey!==undefined) values.apiKey=patch.apiKey??"" + const [row] = await db.update(evobgpSettings).set({...values,updatedAt:sql`now()`}).where(eq(evobgpSettings.id,1)).returning() + if(!row) throw new Error("Сохранение EvoBGP не подтверждено сервером") + return row + }, + })) /** * Прокси к GET /v1/router-lists/catalog — учётные данные только из БД. diff --git a/docs/ux-ui-plan.md b/docs/ux-ui-plan.md index 0d4ba0f1..03f65770 100644 --- a/docs/ux-ui-plan.md +++ b/docs/ux-ui-plan.md @@ -272,3 +272,5 @@ REST запрос → ожидание → подтверждённый output / Локальная приёмка NetFlow 11Б.3.4.4.8: 19 основных и 10 source E2E, backend JWT с purge, backend/frontend builds и API tests прошли в описанных блоках. Дублирующий purge Settings заменён переходом к единой панели (link E2E/typecheck). Исходные lint ошибки Settings остаются до его блока. Внешние PG/WG/RouterOS/Portal/нестандартный IPv4 prefix не приняты. Далее 11Б.3.5.1 — строгие Evolution settings/save/test API и затем provider/форма. Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. + +Завершён 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. diff --git a/docs/ux-ui-progress.md b/docs/ux-ui-progress.md index 17a455f4..1f1d6b22 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.1; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | +| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | В работе: 11Б.1–11Б.3.5.2; scheduler и GeoIP приняты локально; NetFlow принят локально; далее Evolution и общие settings | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат | @@ -36,6 +36,14 @@ +## Блок 11Б.3.5.2 — backend Evolution save/test (2026-10-03) + +- Settings GET/PUT/test вынесены в DI plugin. PUT пишет только переданные нормализованные поля, RETURNING подтверждает их фактические значения, включая apiKey внутри backend; ответ отдаёт только literal ok/public flags/savedFields, без key echo. Singleton insert устойчив к конфликту. +- PUT receipt сохраняет прежние публичные поля верхнего уровня для старого provider, добавляет ok/savedFields. Общий контракт отклоняет пути /v1 вместо прежнего молчаливого удаления пути. Каталог и правила маршрутизации не менялись. +- Проверка соединения подтверждает ожидаемый items array от /v1/modules, а не любой HTTP 200 JSON. Test draft нормализуется общим контрактом; пустой ключ берётся из сохранённой конфигурации, отсутствие URL/key даёт 400. HTTP malformed upstream даёт 502, POST не повторяется. +- DI HTTP и JWT реальных settings/test handlers проверили selective patch, key clear/replace без echo, ignored key 502, DB failure, invalid input/no write, malformed upstream/no success, отсутствие доступа до handler. Backend build и lint прошли; профиль включён в backend test. Реальные PG/Evolution/Portal не проверены. +- Далее 11Б.3.5.3 — provider strict readers/receipts, independent settings/catalog known/error/time, source guards и сохранение последнего каталога. Затем форма Settings/dirty/locks/контекст. Отдельно проверить backend catalog mapper: его upstream optional defaults могут скрывать malformed коллекции. + ## Блок 11Б.3.5.1 — строгий Evolution API (2026-10-03) - Добавлен общий contracts/evobgp: canonical HTTP(S) root URL без пути/query/credentials, явные enabled/secretConfigured, patch/test нормализация ключа без Bearer дублирования, one-line key и strict savedFields receipt. Empty endpoint/key clear и отсутствие поля имеют разную семантику. diff --git a/docs/ux-ui-stage-11b.md b/docs/ux-ui-stage-11b.md index a2ab5b09..f8f91e5e 100644 --- a/docs/ux-ui-stage-11b.md +++ b/docs/ux-ui-stage-11b.md @@ -200,3 +200,5 @@ ReUI PRO/base-nova; существующие Frame, Data Grid v9, KpiStatGrid, S Локальная приёмка NetFlow 11Б.3.4.4.8: 19 основных и 10 source E2E, backend JWT с purge, backend/frontend builds и API tests прошли в описанных блоках. Дублирующий purge Settings заменён переходом к единой панели (link E2E/typecheck). Исходные lint ошибки Settings остаются до его блока. Внешние PG/WG/RouterOS/Portal/нестандартный IPv4 prefix не приняты. Далее 11Б.3.5.1 — строгие Evolution settings/save/test API и затем provider/форма. Завершён 11Б.3.5.1 — contracts/Evolution strict settings/save/test API: 3 unit, contracts build/typecheck/lint прошли. Новый PUT helper пока не подключён (backend старый receipt). Далее 11Б.3.5.2 backend selective save/подтверждение, затем provider и форма. + +Завершён 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.