fix(evolution): подтверждать сохранение ключа и ответ проверки API

This commit is contained in:
Denozordec committed 2026-10-03 13:16:09 +07:00
1 parent 2b62e0e956
commit 554dba55ec
8 files changed
+103 -111

No files matched your search

@@ -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")
+34
View File
@@ -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
View File
@@ -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 — учётные данные только из БД.