fix(evolution): подтверждать сохранение ключа и ответ проверки API
This commit is contained in:
1 parent
2b62e0e956
commit
554dba55ec
8 files changed
+103
-111
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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<SettingsRow>;write:(patch:EvoBgpSavePayload)=>Promise<SettingsRow>;probe:(root:string,key:string)=>Promise<unknown>}):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<string,unknown>)[field]!==undefined)}
|
||||
})
|
||||
}
|
||||
}
|
||||
+17
-109
@@ -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<unknown>(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 — учётные данные только из БД.
|
||||
|
||||
Reference in new issue
Block a user