fix(alerts): отклонять запись устаревшего снимка правил
This commit is contained in:
1 parent
c5c1c2812d
commit
382e318b97
23 files changed
+126
-43
No files matched your search
@@ -2,7 +2,8 @@ import assert from "node:assert/strict"
|
||||
import {test} from "node:test"
|
||||
import {getAlerts,saveAlertRules,saveAlertsTelegram,testAlertsTelegram} from "./alerts"
|
||||
const telegram={chatId:"",tokenConfigured:false,connected:false,messageThreadId:null}
|
||||
const empty={telegram,groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
||||
const revision="1".repeat(64)
|
||||
const empty={revision,telegram,groups:[],rules:[],history:[],meta:{servers:[],greClients:[],probeTargets:[],rttLossTargets:[],trafficServers:[],serversDetail:[]}}
|
||||
test("Alerts reader preserves known empty/false/null and rejects malformed collections, public secrets and dates",async()=>{
|
||||
const original=globalThis.fetch;try{
|
||||
globalThis.fetch=async()=>Response.json(empty);assert.deepEqual(await getAlerts("https://backend.test"),empty)
|
||||
@@ -11,8 +12,8 @@ test("Alerts reader preserves known empty/false/null and rejects malformed colle
|
||||
})
|
||||
test("Alerts mutations require literal complete receipts and never repeat unknown requests",async()=>{
|
||||
const original=globalThis.fetch;try{
|
||||
for(const call of [()=>saveAlertRules("https://backend.test",{rules:[],groups:[]}),()=>saveAlertsTelegram("https://backend.test",{chatId:""}),()=>testAlertsTelegram("https://backend.test")])for(const raw of [{},{ok:false},{ok:"true"},{ok:true,error:"failed"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(call(),/не подтвержден/);assert.equal(calls,1)}
|
||||
globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[]});assert.equal((await saveAlertRules("https://backend.test",{groups:[],rules:[]})).ok,true)
|
||||
for(const call of [()=>saveAlertRules("https://backend.test",{rules:[],groups:[]},{expectedRevision:revision}),()=>saveAlertsTelegram("https://backend.test",{chatId:""}),()=>testAlertsTelegram("https://backend.test")])for(const raw of [{},{ok:false},{ok:"true"},{ok:true,error:"failed"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(call(),/не подтвержден/);assert.equal(calls,1)}
|
||||
globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[]});assert.equal((await saveAlertRules("https://backend.test",{groups:[],rules:[]},{expectedRevision:revision})).ok,true)
|
||||
globalThis.fetch=async()=>Response.json({ok:true});assert.deepEqual(await testAlertsTelegram("https://backend.test"),{ok:true})
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
@@ -33,8 +34,16 @@ test("rules save checks IDs, fields and normalized target/condition arrays witho
|
||||
const original=globalThis.fetch;try{
|
||||
const rule={id:"r",name:"Server",type:"server" as const,target:"host",targets:[" host "],groupId:null,condition:"down",conditions:[" down "],severity:"critical" as const,enabled:false,cooldown:"5м" as const,confirmStabilitySec:0,recoveryMode:"never" as const,recoveryStabilitySec:null,chatId:""}
|
||||
const saved={...rule,targets:["host"],conditions:["down"],confirmStabilitySec:null,lastFiredAt:null}
|
||||
globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[saved]});assert.equal((await saveAlertRules("https://backend.test",{rules:[rule],groups:[]})).rules[0].enabled,false)
|
||||
for(const wrong of [{...saved,enabled:true},{...saved,targets:["other"]},{...saved,name:"Other"}]){globalThis.fetch=async()=>Response.json({ok:true,groups:[],rules:[wrong]});await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule],groups:[]}),/не совпадают/)}
|
||||
let calls=0;globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule,rule]}),/Повторяющиеся/);await assert.rejects(saveAlertRules("https://backend.test",{rules:[{...rule,targets:[" "],conditions:[" "]}]}),/непустые/);assert.equal(calls,0)
|
||||
globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[saved]});assert.equal((await saveAlertRules("https://backend.test",{rules:[rule],groups:[]},{expectedRevision:revision})).rules[0].enabled,false)
|
||||
for(const wrong of [{...saved,enabled:true},{...saved,targets:["other"]},{...saved,name:"Other"}]){globalThis.fetch=async()=>Response.json({ok:true,revision,groups:[],rules:[wrong]});await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule],groups:[]},{expectedRevision:revision}),/не совпадают/)}
|
||||
let calls=0;globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertRules("https://backend.test",{rules:[rule,rule]},{expectedRevision:revision}),/Повторяющиеся/);await assert.rejects(saveAlertRules("https://backend.test",{rules:[{...rule,targets:[" "],conditions:[" "]}]},{expectedRevision:revision}),/непустые/);assert.equal(calls,0)
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
|
||||
test("rules client requires read revision, sends quoted If-Match and does not retry conflict",async()=>{
|
||||
const original=globalThis.fetch;let calls=0;try{
|
||||
globalThis.fetch=async(_url,init)=>{calls++;assert.equal(new Headers(init?.headers).get("If-Match"),'"'+revision+'"');assert.deepEqual(JSON.parse(String(init?.body)),{rules:[],groups:[]});return Response.json({error:"Fixture stale revision"},{status:412})}
|
||||
await assert.rejects(saveAlertRules("https://backend.test",{groups:[],rules:[]}),/Обновите снимок/);assert.equal(calls,0)
|
||||
await assert.rejects(saveAlertRules("https://backend.test",{groups:[],rules:[]},{expectedRevision:revision}),/Fixture stale revision/);assert.equal(calls,1)
|
||||
}finally{globalThis.fetch=original}
|
||||
})
|
||||
@@ -1,13 +1,14 @@
|
||||
import {z} from "zod"
|
||||
import {normalizeAlertsConfig,alertsResponseSchema,alertsRulesReceiptSchema,telegramSettingsReceiptSchema,telegramTestReceiptSchema,putTelegramSchema,testTelegramSchema,type PutRulesBody,type PutTelegramBody,type TestTelegramBody} from "@mmapp/contracts/alerts"
|
||||
import {requestJson} from "./http-client"
|
||||
type Options={signal?:AbortSignal}
|
||||
type Options={signal?:AbortSignal;expectedRevision?:string}
|
||||
const signal=(options?:Options)=>{const active=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);active.throwIfAborted();return active}
|
||||
function parse<S extends z.ZodType>(schema:S,raw:unknown,message:string):z.output<S>{const result=schema.safeParse(raw);if(!result.success)throw Error(message);return result.data}
|
||||
export async function getAlerts(baseUrl:string,options?:Options){return parse(alertsResponseSchema,await requestJson<unknown>(baseUrl,"/api/alerts",{signal:signal(options),cache:"no-store"}),"Данные оповещений не подтверждены сервером")}
|
||||
export async function saveAlertRules(baseUrl:string,payload:PutRulesBody,options?:Options){
|
||||
const body=normalizeAlertsConfig(payload)
|
||||
const receipt=parse(alertsRulesReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/rules",{method:"PUT",body:JSON.stringify(body),signal:signal(options)}),"Сохранение правил не подтверждено сервером")
|
||||
if(!options?.expectedRevision||!/^[a-f0-9]{64}$/.test(options.expectedRevision))throw Error("Обновите снимок правил перед сохранением")
|
||||
const receipt=parse(alertsRulesReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/rules",{method:"PUT",headers:{"If-Match":'"'+options.expectedRevision+'"'},body:JSON.stringify(body),signal:signal(options)}),"Сохранение правил не подтверждено сервером")
|
||||
const same=(a:unknown,b:unknown)=>JSON.stringify(a)===JSON.stringify(b)
|
||||
if(receipt.rules.length!==body.rules.length||body.rules.some(rule=>{
|
||||
const saved=receipt.rules.find(row=>row.id===rule.id)
|
||||
|
||||
Reference in new issue
Block a user