feat(mcp): подготовить сервер и доступ к приёмке

This commit is contained in:
Denozordec committed 2026-11-03 03:12:03 +07:00
commit bf3d50e1ff
3835 files changed
+537122

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {getAlertsOutbox,retryAlertsOutbox} from "./alerts-outbox"
const empty={counts:{total:0,pending:0,processing:0,unknown:0,sent:0,failed:0,unrecognized:0},items:[],itemsLimit:20}
test("queue strict no-store reader distinguishes confirmed zero from malformed or missing held data",async()=>{
const original=globalThis.fetch;try{
globalThis.fetch=async(_url,init)=>{assert.equal(init?.cache,"no-store");return Response.json(empty)};assert.deepEqual(await getAlertsOutbox("https://backend.test"),empty)
for(const raw of [{},{...empty,counts:{...empty.counts,total:1}},{...empty,counts:{...empty.counts,total:1,unknown:1}},{...empty,secret:"private"}]){let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(getAlertsOutbox("https://backend.test"),/не подтверждено/);assert.equal(calls,1)}
const controller=new AbortController();controller.abort();let calls=0;globalThis.fetch=async()=>{calls++;return Response.json(empty)};await assert.rejects(getAlertsOutbox("https://backend.test",{signal:controller.signal}));assert.equal(calls,0)
const late=new AbortController();globalThis.fetch=async()=>{late.abort();return Response.json(empty)};await assert.rejects(getAlertsOutbox("https://backend.test",{signal:late.signal}))
}finally{globalThis.fetch=original}
})
test("queue retry requires risk acknowledgement and strict matching receipt, one POST with no retry",async()=>{
const original=globalThis.fetch;try{let calls=0;globalThis.fetch=async(url,init)=>{calls++;assert.ok(String(url).endsWith("/api/alerts/outbox/a%2Fb/retry"));assert.equal(init?.method,"POST");assert.deepEqual(JSON.parse(String(init?.body)),{expectedVersion:3,acknowledgedDuplicateRisk:true});return Response.json({ok:true,id:"a/b",status:"pending",resolutionVersion:4})}
await assert.rejects(retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:false}));assert.equal(calls,0);assert.equal((await retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:true})).status,"pending");assert.equal(calls,1)
for(const raw of [{},{ok:true,id:"other",status:"pending",resolutionVersion:4},{ok:true,id:"a/b",status:"sent",resolutionVersion:4},{ok:true,id:"a/b",status:"pending",resolutionVersion:3}]){calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(retryAlertsOutbox("https://backend.test","a/b",{expectedVersion:3,acknowledgedDuplicateRisk:true}),/не подтверждена/);assert.equal(calls,1)}
}finally{globalThis.fetch=original}
})
+17
View File
@@ -0,0 +1,17 @@
import {alertsOutboxOverviewSchema,alertsOutboxRetrySchema,alertsOutboxRetryReceiptSchema} from "@mmapp/contracts/alerts"
import {requestJson} from "./http-client"
export async function getAlertsOutbox(base:string,options?:{signal?:AbortSignal}){
const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);signal.throwIfAborted()
const parsed=alertsOutboxOverviewSchema.safeParse(await requestJson<unknown>(base,"/api/alerts/outbox",{signal,cache:"no-store"}));signal.throwIfAborted()
if(!parsed.success)throw Error("Состояние очереди не подтверждено сервером")
return parsed.data
}
export async function retryAlertsOutbox(base:string,id:string,input:unknown,options?:{signal?:AbortSignal}){
const body=alertsOutboxRetrySchema.safeParse(input);if(!body.success||!id.trim()||id.length>200)throw Error("Проверьте подтверждение и версию сообщения")
const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000);signal.throwIfAborted()
const raw=await requestJson<unknown>(base,"/api/alerts/outbox/"+encodeURIComponent(id)+"/retry",{method:"POST",body:JSON.stringify(body.data),signal,cache:"no-store"});signal.throwIfAborted()
const parsed=alertsOutboxRetryReceiptSchema.safeParse(raw)
if(!parsed.success||parsed.data.id!==id||parsed.data.resolutionVersion!==body.data.expectedVersion+1)throw Error("Постановка сообщения в очередь не подтверждена сервером")
return parsed.data
}
+8
View File
@@ -0,0 +1,8 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {getAlertsBgpTargets,getAlertsServerTargets,getAlertsGreTargets} from "./alerts-targets"
test("Alerts target readers preserve known empty collections and unknown optional GRE status",async()=>{const original=globalThis.fetch;try{globalThis.fetch=async()=>Response.json([]);assert.deepEqual(await getAlertsBgpTargets("https://backend.test"),[]);assert.deepEqual(await getAlertsServerTargets("https://backend.test"),[]);globalThis.fetch=async()=>Response.json({tunnels:[]});assert.deepEqual(await getAlertsGreTargets("https://backend.test"),{tunnels:[]});globalThis.fetch=async()=>Response.json({tunnels:[{name:"gre1",serverId:"1"}]});assert.equal((await getAlertsGreTargets("https://backend.test")).tunnels[0].status,undefined)}finally{globalThis.fetch=original}})
test("Alerts malformed secondary catalogs reject instead of fabricating empty/down",async()=>{const original=globalThis.fetch;try{for(const [call,raw] of [[getAlertsBgpTargets,{}],[getAlertsBgpTargets,[{serverName:"",name:"peer"}]],[getAlertsServerTargets,[{id:"1",name:"server"}]],[getAlertsServerTargets,[{id:1.5,name:"server"}]],[getAlertsGreTargets,{}],[getAlertsGreTargets,{tunnels:null}],[getAlertsGreTargets,{tunnels:[{name:"gre",serverId:""}]}]] as const){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(call("https://backend.test"),/не подтверждён/)}}finally{globalThis.fetch=original}})
test("Alerts target readers reject pre-abort before GET and use independent bounded signals",async()=>{const original=globalThis.fetch;let calls=0;try{globalThis.fetch=async()=>{calls++;return Response.json([])};const controller=new AbortController();controller.abort();for(const call of [getAlertsBgpTargets,getAlertsServerTargets,getAlertsGreTargets])await assert.rejects(call("https://backend.test",{signal:controller.signal}));assert.equal(calls,0);globalThis.fetch=async(_url,init)=>{assert.ok(init?.signal);return Response.json([])};await getAlertsBgpTargets("https://backend.test")}finally{globalThis.fetch=original}})
test("Alerts GRE reader preserves partial device failures instead of treating missing tunnels as complete",async()=>{const original=globalThis.fetch;try{const failures=[{serverId:"1",serverName:"Router",error:"unavailable"}];globalThis.fetch=async()=>Response.json({tunnels:[],failures});assert.deepEqual((await getAlertsGreTargets("https://backend.test")).failures,failures)}finally{globalThis.fetch=original}})
+17
View File
@@ -0,0 +1,17 @@
import {z} from "zod"
import {requestJson} from "./http-client"
const bgpSchema=z.array(z.object({serverName:z.string().trim().min(1),name:z.string().trim().min(1)}))
const serversSchema=z.array(z.object({id:z.number().int().positive(),name:z.string(),site:z.string().optional(),country:z.string().optional(),host:z.string().optional()}))
const greSchema=z.object({tunnels:z.array(z.object({id:z.string().optional(),name:z.string(),serverId:z.string().min(1),remoteAddress:z.string().optional(),status:z.string().optional(),comment:z.string().optional()})),failures:z.array(z.object({serverId:z.string().min(1),serverName:z.string(),error:z.string()})).optional()})
type Options={signal?:AbortSignal}
async function read<S extends z.ZodType>(base:string,path:string,schema:S,message:string,options?:Options):Promise<z.output<S>>{
const signal=options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(30_000)]):AbortSignal.timeout(30_000)
signal.throwIfAborted()
const parsed=schema.safeParse(await requestJson<unknown>(base,path,{signal,cache:"no-store"}))
signal.throwIfAborted()
if(!parsed.success)throw Error(message)
return parsed.data
}
export const getAlertsBgpTargets=(base:string,options?:Options)=>read(base,"/api/bgp/sessions",bgpSchema,"Каталог BGP для оповещений не подтверждён",options)
export const getAlertsServerTargets=(base:string,options?:Options)=>read(base,"/api/servers",serversSchema,"Каталог серверов для оповещений не подтверждён",options)
export const getAlertsGreTargets=(base:string,options?:Options)=>read(base,"/api/filters/gre-tunnels",greSchema,"Каталог GRE для оповещений не подтверждён",options)
+49
View File
@@ -0,0 +1,49 @@
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 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)
for(const raw of [{},{...empty,rules:null},{...empty,groups:undefined},{...empty,telegram:{...telegram,connected:true}},{...empty,telegram:{...telegram,token:"secret"}},{...empty,history:[{id:"h",ruleName:"r",severity:"info",message:"m",sent:false,firedAt:"invalid",source:"db"}]}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getAlerts("https://backend.test"),/не подтверждены/)}
}finally{globalThis.fetch=original}
})
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:[]},{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}
})
test("Telegram selective save confirms sent values and rejects mismatched configured secret/thread/chat",async()=>{
const original=globalThis.fetch;try{
globalThis.fetch=async(_url,init)=>{assert.deepEqual(JSON.parse(init?.body as string),{chatId:" 123 ",messageThreadId:null});return Response.json({ok:true,telegram:{...telegram,chatId:"123"}})}
assert.equal((await saveAlertsTelegram("https://backend.test",{chatId:" 123 ",messageThreadId:null})).telegram.chatId,"123")
globalThis.fetch=async()=>Response.json({ok:true,telegram})
for(const patch of [{chatId:"123"},{messageThreadId:1},{token:"fixture-secret"}])await assert.rejects(saveAlertsTelegram("https://backend.test",patch),/не совпадают/)
assert.equal((await saveAlertsTelegram("https://backend.test",{token:null})).telegram.tokenConfigured,false)
}finally{globalThis.fetch=original}
})
test("invalid Telegram thread/empty patch and abort do not send requests",async()=>{
const original=globalThis.fetch;let calls=0;try{globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};await assert.rejects(saveAlertsTelegram("https://backend.test",{}),/Нет изменённых/);for(const id of [0,-1,1.5,NaN]){await assert.rejects(saveAlertsTelegram("https://backend.test",{messageThreadId:id}),/Проверьте/);await assert.rejects(testAlertsTelegram("https://backend.test",{messageThreadId:id}),/Проверьте/)}const controller=new AbortController();controller.abort();await assert.rejects(getAlerts("https://backend.test",{signal:controller.signal}));assert.equal(calls,0)}finally{globalThis.fetch=original}
})
test("rules save checks IDs, fields and normalized target/condition arrays without substituting computed labels",async()=>{
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,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}
})
+33
View File
@@ -0,0 +1,33 @@
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;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)
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)
if(!saved)return true
const targets=(rule.targets?.length?rule.targets:[rule.target??""]).map(value=>value.trim()).filter(Boolean)
const conditions=(rule.conditions?.length?rule.conditions:[rule.condition??""]).map(value=>value.trim()).filter(Boolean)
const expected={name:rule.name,type:rule.type,targets,conditions,groupId:rule.groupId?.trim()||null,severity:rule.severity,enabled:rule.enabled,cooldown:rule.cooldown,confirmStabilitySec:rule.confirmStabilitySec||null,recoveryMode:rule.recoveryMode??"always",recoveryStabilitySec:rule.recoveryStabilitySec||null,chatId:rule.chatId}
return Object.entries(expected).some(([key,value])=>!same(saved[key as keyof typeof saved],value))
})||body.groups!==undefined&&(receipt.groups.length!==body.groups.length||body.groups.some(group=>{const saved=receipt.groups.find(row=>row.id===group.id);return !saved||Object.entries({...group,name:group.name.trim()||group.id}).some(([key,value])=>!same(saved[key as keyof typeof saved],value))})))throw Error("Сохранённые правила или группы не совпадают с отправленными")
return receipt
}
export async function saveAlertsTelegram(baseUrl:string,payload:PutTelegramBody,options?:Options){
const body=parse(putTelegramSchema,payload,"Проверьте параметры Telegram")
if(Object.keys(body).length===0)throw Error("Нет изменённых параметров Telegram")
const receipt=parse(telegramSettingsReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/telegram",{method:"PUT",body:JSON.stringify(body),signal:signal(options)}),"Сохранение параметров Telegram не подтверждено сервером")
if(body.chatId!==undefined&&receipt.telegram.chatId!==body.chatId.trim()||body.messageThreadId!==undefined&&receipt.telegram.messageThreadId!==body.messageThreadId||body.token!==undefined&&receipt.telegram.tokenConfigured!==Boolean(body.token?.trim()))throw Error("Сохранённые параметры Telegram не совпадают с отправленными")
return receipt
}
export async function testAlertsTelegram(baseUrl:string,payload:TestTelegramBody={},options?:Options){
const body=parse(testTelegramSchema,payload,"Проверьте параметры тестового сообщения Telegram")
return parse(telegramTestReceiptSchema,await requestJson<unknown>(baseUrl,"/api/alerts/telegram/test",{method:"POST",body:JSON.stringify(body),signal:signal(options)}),"Отправка тестового сообщения не подтверждена сервером")
}
+5
View File
@@ -0,0 +1,5 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {confirmBackendHealth,getBackendHealth} from "./backend-health"
test("health confirms actual status ok and legacy literal ok true only",()=>{for(const value of [{status:"ok",timestamp:"2026-10-03"},{ok:true}])assert.doesNotThrow(()=>confirmBackendHealth(value));for(const value of [null,{},[],{ok:false},{ok:"true"},{status:"error"},"ok"])assert.throws(()=>confirmBackendHealth(value),/не подтверждена/)})
test("HTTP success alone cannot confirm backend, signals and no retries are preserved",async()=>{const original=globalThis.fetch;let calls=0;try{const controller=new AbortController();globalThis.fetch=async(_url,init)=>{calls++;assert.equal(init?.signal instanceof AbortSignal,true);return new Response("<html>proxy</html>",{status:200})};await assert.rejects(()=>getBackendHealth("https://backend.test",controller.signal));assert.equal(calls,1);globalThis.fetch=async()=>{calls++;return Response.json({status:"ok"})};await getBackendHealth("https://backend.test");assert.equal(calls,2)}finally{globalThis.fetch=original}})
+9
View File
@@ -0,0 +1,9 @@
import {resolveApiUrl} from "./http-client"
export function confirmBackendHealth(raw:unknown):void {
if(typeof raw!=="object"||raw===null||(!("status" in raw&&raw.status==="ok")&&!("ok" in raw&&raw.ok===true)))throw new Error("Доступность backend не подтверждена")
}
export async function getBackendHealth(baseUrl:string,signal?:AbortSignal):Promise<void>{
const response=await fetch(resolveApiUrl(baseUrl,"/health"),{cache:"no-store",signal:signal?AbortSignal.any([signal,AbortSignal.timeout(3000)]):AbortSignal.timeout(3000)})
if(!response.ok)throw new Error("Backend недоступен")
confirmBackendHealth(await response.json())
}
+118
View File
@@ -0,0 +1,118 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { listBackups, downloadBackup, createBackupsAsync, getBackupJob, deleteBackup, restoreBackup, getBackupScheduleSettings, putBackupScheduleSettings, getBackupStorageSettings, putBackupStorageSettings, testBackupStorage, syncBackupsFromStorage } from "./backups"
const base = "https://api.example.test"
const item = { id: "backup-1", serverId: 1, serverName: "router", filename: "config.rsc", sizeBytes: 0, createdAt: "2026-10-01T00:00:00Z", kind: "manual", storage: "local" }
const schedule = { enabled: false, frequency: "daily", hour: 0, minute: 0, weekDay: 0, monthDay: 1, keepCount: 7, format: "rsc", serverIds: [] }
const storage = { provider: "local", s3Endpoint: "", s3Region: "", s3Bucket: "", s3Prefix: "", s3AccessKeyId: "", secretConfigured: false, s3ForcePathStyle: false, keepLocalCopy: false }
const job = { id: "job-1", serverIds: ["1", "2"], status: "done", total: 2, completed: 2, requestedAt: "2026-10-01T00:00:00Z", finishedAt: "2026-10-01T00:01:00Z", created: [item], failures: [{ serverId: "2", error: "RouterOS unavailable" }] }
function json(payload: unknown, status = 200) { return new Response(JSON.stringify(payload), { status }) }
test("known empty and zero are preserved; malformed catalogue and duplicates rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json([]); assert.deepEqual(await listBackups(base), [])
globalThis.fetch = async () => json([item]); assert.equal((await listBackups(base))[0].sizeBytes, 0)
for (const payload of [{}, [item, item], [{ ...item, sizeBytes: -1 }], [{ ...item, serverId: "1" }], [{ ...item, createdAt: "bad" }], [{ ...item, storage: undefined }]]) {
globalThis.fetch = async () => json(payload); await assert.rejects(() => listBackups(base))
}
} finally { globalThis.fetch = original }
})
test("settings require a confirmed response and preserve false rather than defaults", async () => {
const original = globalThis.fetch
try {
for (const read of [getBackupScheduleSettings, getBackupStorageSettings]) {
globalThis.fetch = async () => json({}); await assert.rejects(() => read(base))
globalThis.fetch = async () => new Response(null, { status: 204 }); await assert.rejects(() => read(base))
}
globalThis.fetch = async url => json(String(url).endsWith("schedule") ? schedule : storage)
assert.equal((await getBackupScheduleSettings(base)).enabled, false)
assert.equal((await getBackupStorageSettings(base)).secretConfigured, false)
assert.equal((await putBackupScheduleSettings(base, { enabled: false })).enabled, false)
assert.equal((await putBackupStorageSettings(base, { keepLocalCopy: false })).keepLocalCopy, false)
} finally { globalThis.fetch = original }
})
test("invalid mutations fail before HTTP; create receives job acknowledgment", async () => {
const original = globalThis.fetch; let calls = 0, body: unknown
globalThis.fetch = async (_url, init) => { calls++; body = JSON.parse(String(init?.body)); return json({ jobId: "job-1", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
try {
await assert.rejects(() => createBackupsAsync(base, { serverIds: [] }))
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1", "1"] }))
await assert.rejects(() => createBackupsAsync(base, { serverIds: ["0"] }))
await assert.rejects(() => putBackupScheduleSettings(base, { hour: 24 }))
assert.equal(calls, 0)
assert.equal((await createBackupsAsync(base, { serverIds: ["1"], notes: "manual" })).jobId, "job-1")
assert.deepEqual(body, { serverIds: ["1"], notes: "manual" })
globalThis.fetch = async () => json({ jobId: "job-1", serverIds: ["1", "2"], status: "queued", total: 2, completed: 0 }, 202); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }), /выбранные серверы/);
globalThis.fetch = async () => json({ created: [item], failures: [] }); await assert.rejects(() => createBackupsAsync(base, { serverIds: ["1"] }))
} finally { globalThis.fetch = original }
})
test("partial completion is valid, foreign and inconsistent job observations rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(job)
assert.equal((await getBackupJob(base, "job-1")).failures[0].error, "RouterOS unavailable")
await assert.rejects(() => getBackupJob(base, "job-other"), /другой задаче/)
for (const patch of [{ completed: 3 }, { completed: 1 }, { finishedAt: undefined }, { created: [item, item] }, { failures: [] }]) {
globalThis.fetch = async () => json({ ...job, ...patch }); await assert.rejects(() => getBackupJob(base, "job-1"))
}
globalThis.fetch = async () => json({ ...job, status: "failed", completed: 1, failures: [{ serverId: "job", error: "interrupted" }] })
assert.equal((await getBackupJob(base, "job-1")).status, "failed")
} finally { globalThis.fetch = original }
})
test("delete requires 204, encodes ID and carries caller cancellation", async () => {
const original = globalThis.fetch; let url = "", signal: AbortSignal | null | undefined
try {
globalThis.fetch = async (input, init) => { url = String(input); signal = init?.signal; return json({ ok: true }) }
await assert.rejects(() => deleteBackup(base, "a/b"), /204/); assert.ok(url.endsWith("a%2Fb"))
globalThis.fetch = async (_input, init) => { signal = init?.signal; return new Response(null, { status: 204 }) }
const abort = new AbortController(); await deleteBackup(base, "backup-1", { signal: abort.signal }); abort.abort(); assert.equal(signal?.aborted, true)
globalThis.fetch = async () => json({ error: "Нет доступа" }, 403); await assert.rejects(() => deleteBackup(base, "backup-1"), /Нет доступа/)
} finally { globalThis.fetch = original }
})
test("restore, S3 test and sync do not accept empty or malformed success", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json({}); await assert.rejects(() => restoreBackup(base, "backup-1")); await assert.rejects(() => testBackupStorage(base)); await assert.rejects(() => syncBackupsFromStorage(base))
globalThis.fetch = async () => json({ backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "router" }); assert.equal((await restoreBackup(base, "backup-1")).filename, "config.rsc")
globalThis.fetch = async () => json(storage); assert.equal((await testBackupStorage(base)).secretConfigured, false)
globalThis.fetch = async () => json({ imported: 0, skipped: 0 }); assert.equal((await syncBackupsFromStorage(base)).imported, 0)
globalThis.fetch = async () => json({ imported: -1, skipped: 0 }); await assert.rejects(() => syncBackupsFromStorage(base))
} finally { globalThis.fetch = original }
})
test("settings acknowledgment must match submitted fields and confirm a new secret", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(schedule); await assert.rejects(() => putBackupScheduleSettings(base, { hour: 7 }), /не подтверждено/)
globalThis.fetch = async () => json(storage); await assert.rejects(() => putBackupStorageSettings(base, { provider: "s3" }), /не подтверждено/)
await assert.rejects(() => putBackupStorageSettings(base, { s3SecretAccessKey: "new-test-secret" }), /ключа/)
} finally { globalThis.fetch = original }
})
test("backup request ID is sent and a different acknowledgment is rejected", async () => {
const original = globalThis.fetch, requestId = "11111111-1111-4111-8111-111111111111"; let body: Record<string, unknown> = {}
globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return json({ jobId: "other", serverIds: ["1"], status: "queued", total: 1, completed: 0 }, 202) }
try { await assert.rejects(() => createBackupsAsync(base, { requestId, serverIds: ["1"] }), /другому запросу/); assert.equal(body.requestId, requestId) } finally { globalThis.fetch = original }
})
test("download encodes identity and refuses HTML/JSON success bodies", async () => {
const original = globalThis.fetch; let url = ""
try {
for (const type of ["text/html", "application/json", ""]) { globalThis.fetch = async input => { url = String(input); return new Response("wrong body", { headers: { "Content-Type": type } }) }; await assert.rejects(() => downloadBackup(base, "a/b"), /не подтверждён/) }
assert.ok(url.endsWith("a%2Fb/download"))
globalThis.fetch = async () => new Response("config", { headers: { "Content-Type": "text/plain; charset=utf-8" } }); assert.equal(await (await downloadBackup(base, "backup-1")).text(), "config")
} finally { globalThis.fetch = original }
})
test("restore receipt matches backup and device identity", async () => {
const original = globalThis.fetch
try {
const receipt = { backupId: "backup-1", serverId: 1, filename: "config.rsc", serverName: "renamed router" }
for (const patch of [{ backupId: "other" }, { serverId: 2 }, { backupId: undefined }, { serverId: undefined }, { serverId: "1" }]) {
globalThis.fetch = async () => json({ ...receipt, ...patch }); await assert.rejects(() => restoreBackup(base, "backup-1", undefined, 1))
}
globalThis.fetch = async () => json(receipt); assert.equal((await restoreBackup(base, "backup-1", undefined, 1)).serverName, "renamed router")
} finally { globalThis.fetch = original }
})
+89
View File
@@ -0,0 +1,89 @@
import {
backupJobResponseSchema,
backupScheduleSettingsDtoSchema,
backupStorageSettingsDtoSchema,
backupsListResponseSchema,
createBackupJobResponseSchema,
createBackupRequestSchema,
putBackupScheduleSettingsSchema,
putBackupStorageSettingsSchema,
restoreBackupResponseSchema,
syncBackupsResponseSchema,
} from "@mmapp/contracts/backups"
import type { BackupItemDto, BackupScheduleSettingsDto, PutBackupStorageSettings } from "@mmapp/contracts/backups"
import { requestBlob, requestJson } from "@/shared/api/http-client"
export type BackupItem = BackupItemDto
export type { CreateBackupJobResponse, BackupJobStatusResponse } from "@mmapp/contracts/backups"
function backupOptions(options?: RequestInit): RequestInit {
return { ...options, signal: AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)]) }
}
function backupJson(baseUrl: string, path: string, options?: RequestInit) {
return requestJson<unknown>(baseUrl, path, backupOptions(options))
}
export async function listBackups(baseUrl: string, options?: RequestInit) {
return backupsListResponseSchema.parse(await backupJson(baseUrl, "/api/backups", { ...options, method: "GET" }))
}
export async function createBackupsAsync(baseUrl: string, payload: { requestId?: string; serverIds: string[]; notes?: string }, options?: RequestInit) {
const body = createBackupRequestSchema.parse(payload)
const result = createBackupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/create", { ...options, method: "POST", body: JSON.stringify(body) }))
if (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу резервного копирования")
if (JSON.stringify(result.serverIds) !== JSON.stringify(body.serverIds.map(String))) throw new Error("Не подтверждены выбранные серверы резервного копирования")
if (result.total !== body.serverIds.length) throw new Error("Не подтверждено резервное копирование всех выбранных серверов")
return result
}
export async function getBackupJob(baseUrl: string, jobId: string, options?: RequestInit) {
const result = backupJobResponseSchema.parse(await backupJson(baseUrl, "/api/backups/jobs/" + encodeURIComponent(jobId), { ...options, method: "GET" }))
if (result.id !== jobId) throw new Error("Ответ относится к другой задаче резервного копирования")
return result
}
export async function deleteBackup(baseUrl: string, id: string, options?: RequestInit, recordOnly = false): Promise<void> {
const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id) + (recordOnly ? "?recordOnly=true" : ""), backupOptions({ ...options, method: "DELETE" }))
if (response.status !== 204) throw new Error("Удаление резервной копии не подтверждено: ожидается HTTP 204")
if (recordOnly && response.headers.get("X-Backup-Delete-Mode") !== "record-only") throw new Error("Не подтверждено удаление только записи истории. Обновите данные перед повтором.")
}
export async function restoreBackup(baseUrl: string, id: string, options?: RequestInit, expectedServerId?: number) {
const result = restoreBackupResponseSchema.parse(await backupJson(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/restore", { ...options, method: "POST" }))
if (result.backupId !== id || (expectedServerId !== undefined && result.serverId !== expectedServerId)) throw new Error("Ответ восстановления относится к другой резервной копии или серверу")
return result
}
export async function getBackupScheduleSettings(baseUrl: string, options?: RequestInit) {
return backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "GET" }))
}
function confirmSettings(result: Record<string, unknown>, payload: Record<string, unknown>) {
for (const [key, value] of Object.entries(payload)) {
if (value === undefined) continue
if (key === "s3SecretAccessKey") {
if (value && result.secretConfigured !== true) throw new Error("Сохранение ключа хранилища не подтверждено")
} else if (JSON.stringify(result[key]) !== JSON.stringify(value)) throw new Error("Сохранение настроек не подтверждено: ответ отличается от отправленных данных")
}
}
export async function putBackupScheduleSettings(baseUrl: string, payload: Partial<BackupScheduleSettingsDto>, options?: RequestInit) {
const body = putBackupScheduleSettingsSchema.parse(payload)
const result = backupScheduleSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/schedule", { ...options, method: "PUT", body: JSON.stringify(body) }))
confirmSettings(result, body)
return result
}
export async function getBackupStorageSettings(baseUrl: string, options?: RequestInit) {
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "GET" }))
}
export async function putBackupStorageSettings(baseUrl: string, payload: PutBackupStorageSettings, options?: RequestInit) {
const body = putBackupStorageSettingsSchema.parse(payload)
const result = backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage", { ...options, method: "PUT", body: JSON.stringify(body) }))
confirmSettings(result, body)
return result
}
export async function testBackupStorage(baseUrl: string, options?: RequestInit) {
return backupStorageSettingsDtoSchema.parse(await backupJson(baseUrl, "/api/backups/storage/test", { ...options, method: "POST" }))
}
export async function syncBackupsFromStorage(baseUrl: string, options?: RequestInit) {
return syncBackupsResponseSchema.parse(await backupJson(baseUrl, "/api/backups/storage/sync", { ...options, method: "POST" }))
}
export async function downloadBackup(baseUrl: string, id: string, options?: RequestInit) {
const response = await requestBlob(baseUrl, "/api/backups/" + encodeURIComponent(id) + "/download", backupOptions({ ...options, method: "GET" }))
const type = response.headers.get("Content-Type")?.split(";")[0].trim().toLowerCase()
if (type !== "text/plain" && type !== "application/octet-stream") throw new Error("Файл резервной копии не подтверждён API")
return response.blob()
}
+80
View File
@@ -0,0 +1,80 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { createCertificateIssueJob, getCertificateIssueJob, getAcmeSettings, putAcmeSettings, testAcmeSettings, getCertificateRenewSettings, putCertificateRenewSettings } from "./certificates"
const base = "https://api.example.test"
const issue = { serverId: "1", certName: "cert", domainNames: ["test.example"] }
const acme = { directoryUrl: "https://acme.example.test/directory", tokenConfigured: false }
const renew = { enabled: false, intervalSec: 300, renewBeforeDays: 30 }
const job = { id: "job-1", serverId: "1", certName: "cert", domainNames: ["test.example"], status: "queued", step: "queued", requestedAt: "2026-10-01T00:00:00Z" }
test("settings readers reject malformed and empty responses instead of defaults", async () => {
const original = globalThis.fetch
try {
for (const payload of [{}, [], { ...acme, tokenConfigured: "false" }, { ...renew, enabled: "false" }]) {
globalThis.fetch = async () => new Response(JSON.stringify(payload), { status: 200 })
await assert.rejects(() => getAcmeSettings(base))
await assert.rejects(() => getCertificateRenewSettings(base))
}
globalThis.fetch = async () => new Response(null, { status: 204 })
await assert.rejects(() => putAcmeSettings(base, {}))
await assert.rejects(() => putCertificateRenewSettings(base, { enabled: true }))
} finally { globalThis.fetch = original }
})
test("strict settings preserve false, carry caller cancellation and PUT body", async () => {
const original = globalThis.fetch; let init: RequestInit | undefined
globalThis.fetch = async (url, options) => { init = options; return new Response(JSON.stringify(String(url).includes("renew") ? renew : acme), { status: 200 }) }
try {
const abort = new AbortController()
assert.equal((await getAcmeSettings(base, { signal: abort.signal })).tokenConfigured, false)
const signal = init?.signal; abort.abort(); assert.equal(signal?.aborted, true)
assert.equal((await putCertificateRenewSettings(base, { enabled: false })).enabled, false)
assert.equal(init?.method, "PUT"); assert.equal(init?.body, JSON.stringify({ enabled: false }))
} finally { globalThis.fetch = original }
})
test("invalid request fields fail before sending mutation", async () => {
const original = globalThis.fetch; let calls = 0
globalThis.fetch = async () => { calls++; return new Response("{}", { status: 200 }) }
try {
await assert.rejects(() => putAcmeSettings(base, { directoryUrl: "bad" }))
await assert.rejects(() => putCertificateRenewSettings(base, { intervalSec: 0 }))
await assert.rejects(() => createCertificateIssueJob(base, { ...issue, domainNames: [] }))
assert.equal(calls, 0)
} finally { globalThis.fetch = original }
})
test("issue acknowledgment and polling require matching job identity", async () => {
const original = globalThis.fetch
try {
for (const payload of [{}, { jobId: "" }, { jobId: " " }]) {
globalThis.fetch = async () => new Response(JSON.stringify(payload), { status: 200 })
await assert.rejects(() => createCertificateIssueJob(base, issue))
}
globalThis.fetch = async () => new Response(JSON.stringify(job), { status: 200 })
assert.equal((await getCertificateIssueJob(base, "job-1")).status, "queued")
await assert.rejects(() => getCertificateIssueJob(base, "job-other"), /другой задаче/)
} finally { globalThis.fetch = original }
})
test("Cloudflare failure preserves backend message and malformed success is rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => new Response(JSON.stringify({ ok: false, message: "Cloudflare отказал" }), { status: 400 })
await assert.rejects(() => testAcmeSettings(base), /Cloudflare отказал/)
globalThis.fetch = async () => new Response(JSON.stringify({}), { status: 200 })
await assert.rejects(() => testAcmeSettings(base))
globalThis.fetch = async () => new Response(JSON.stringify({ ok: false, message: "denied" }), { status: 200 })
assert.equal((await testAcmeSettings(base)).ok, false)
} finally { globalThis.fetch = original }
})
test("requestId survives POST and mismatched acknowledgement is rejected", async () => {
const original = globalThis.fetch; const requestId = "11111111-1111-4111-8111-111111111111"; let returnedId = "other"; let body: Record<string, unknown> = {}
globalThis.fetch = async (_url, init) => { body = JSON.parse(String(init?.body)); return new Response(JSON.stringify({ jobId: returnedId }), { status: 200 }) }
try {
await assert.rejects(() => createCertificateIssueJob(base, { ...issue, requestId }), /другому запросу/)
assert.equal(body.requestId, requestId)
returnedId = requestId; assert.equal((await createCertificateIssueJob(base, { ...issue, requestId })).jobId, requestId)
} finally { globalThis.fetch = original }
})
+79
View File
@@ -0,0 +1,79 @@
import { z } from "zod"
import {
acmeCloudflareSettingsDtoSchema,
certificateIssueJobSchema,
certificateIssueRequestSchema,
certificateRenewSettingsDtoSchema,
certificatesListResponseSchema,
certificatesStateSchema,
putAcmeCloudflareSettingsSchema,
putCertificateRenewSettingsSchema,
testAcmeCloudflareSettingsSchema,
} from "@mmapp/contracts/certificates"
import type { CertificateIssueRequest } from "@mmapp/contracts/certificates"
import { ApiClientError, requestJson } from "@/shared/api/http-client"
const issueAckSchema = z.object({ jobId: z.string().min(1).refine(value => value.trim() === value) })
const acmeTestSchema = z.object({ ok: z.boolean(), message: z.string().optional() })
function certificateJson(baseUrl: string, path: string, options?: RequestInit) {
const signal = AbortSignal.any([...(options?.signal ? [options.signal] : []), AbortSignal.timeout(30_000)])
return requestJson<unknown>(baseUrl, path, { ...options, signal })
}
export async function listCertificates(baseUrl: string, options?: RequestInit) {
return certificatesListResponseSchema.parse(await certificateJson(baseUrl, "/api/certificates", { ...options, method: "GET" }))
}
export async function refreshCertificates(baseUrl: string, options?: RequestInit) {
return certificatesListResponseSchema.parse(await certificateJson(baseUrl, "/api/certificates/refresh", { ...options, method: "POST" }))
}
export async function createCertificateIssueJob(baseUrl: string, payload: CertificateIssueRequest, options?: RequestInit) {
const body = certificateIssueRequestSchema.parse(payload)
const result = issueAckSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue", { ...options, method: "POST", body: JSON.stringify(body) }))
if (body.requestId && result.jobId !== body.requestId) throw new Error("Подтверждение относится к другому запросу выпуска")
return result
}
export async function getCertificateIssueJob(baseUrl: string, jobId: string, options?: RequestInit) {
const result = certificateIssueJobSchema.parse(await certificateJson(baseUrl, "/api/certificates/issue/" + encodeURIComponent(jobId), { ...options, method: "GET" }))
if (result.id !== jobId) throw new Error("Ответ относится к другой задаче выпуска")
return result
}
export async function getAcmeSettings(baseUrl: string, options?: RequestInit) {
return acmeCloudflareSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings", { ...options, method: "GET" }))
}
export async function putAcmeSettings(baseUrl: string, payload: { directoryUrl?: string; defaultZoneId?: string | null; cloudflareApiToken?: string | null }, options?: RequestInit) {
const body = putAcmeCloudflareSettingsSchema.parse(payload)
return acmeCloudflareSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings", { ...options, method: "PUT", body: JSON.stringify(body) }))
}
export async function testAcmeSettings(baseUrl: string, payload?: { cloudflareApiToken?: string }, options?: RequestInit) {
const body = testAcmeCloudflareSettingsSchema.parse(payload ?? {})
try {
return acmeTestSchema.parse(await certificateJson(baseUrl, "/api/certificates/acme-settings/test", { ...options, method: "POST", body: JSON.stringify(body) }))
} catch (error) {
// Backend возвращает message для отказа Cloudflare, а не стандартное error.
if (error instanceof ApiClientError) {
const failure = acmeTestSchema.safeParse(error.payload)
if (failure.success && !failure.data.ok && failure.data.message) throw new ApiClientError(failure.data.message, error.status, error.payload)
}
throw error
}
}
export async function getCertificateRenewSettings(baseUrl: string, options?: RequestInit) {
return certificateRenewSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/renew-settings", { ...options, method: "GET" }))
}
export async function putCertificateRenewSettings(baseUrl: string, payload: { enabled?: boolean; intervalSec?: number; renewBeforeDays?: number }, options?: RequestInit) {
const body = putCertificateRenewSettingsSchema.parse(payload)
return certificateRenewSettingsDtoSchema.parse(await certificateJson(baseUrl, "/api/certificates/renew-settings", { ...options, method: "PUT", body: JSON.stringify(body) }))
}
export async function readCertificatesState(baseUrl: string, options?: RequestInit) {
return certificatesStateSchema.parse(await certificateJson(baseUrl, "/api/certificates/state", { ...options, method: "GET" }))
}
@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { putTrafficCollectorSettings, putServersApiPingSettings, putUptimeCollectorSettings, putInternetPathSettings } from "./collector-settings"
const common = { enabled: false, intervalSec: 300, retentionDays: 14, lastCollectedAt: null, lastDurationMs: 0, lastError: null }
const uptime = { ...common, resourcesEnabled: false, pingEnabled: false, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 }
for (const [name, put, path, dto] of [
["traffic", putTrafficCollectorSettings, "/api/traffic/settings", common],
["REST ping", putServersApiPingSettings, "/api/servers-api-ping/settings", common],
["uptime", putUptimeCollectorSettings, "/api/uptime/settings", uptime],
["internet", putInternetPathSettings, "/api/internet-path/settings", common],
] as const) test(name + ": selective PUT requires exact explicit receipt without retries", async () => {
const original = globalThis.fetch; let calls = 0
try {
globalThis.fetch = async (url, init) => { calls++; assert.equal(new URL(String(url)).pathname, path); assert.equal(init?.method, "PUT"); assert.deepEqual(JSON.parse(String(init.body)), { intervalSec: 300 }); return new Response(JSON.stringify({ ok: true, settings: dto })) }
assert.equal((await put("http://fixture.test", { intervalSec: 300 })).intervalSec, 300); assert.equal(calls, 1)
for (const raw of [{}, { ok: false, settings: dto }, { ok: true, settings: {} }, { ok: true, settings: { ...dto, intervalSec: 301 } }]) {
calls = 0; globalThis.fetch = async () => { calls++; return new Response(JSON.stringify(raw)) }
await assert.rejects(() => put("http://fixture.test", { intervalSec: 300 }), /подтвер/); assert.equal(calls, 1)
}
for (const payload of [{ intervalSec: 0 }, { intervalSec: 30.5 }, { intervalSec: NaN }, {}]) {
calls = 0; await assert.rejects(() => put("http://fixture.test", payload)); assert.equal(calls, 0)
}
} finally { globalThis.fetch = original }
})
+44
View File
@@ -0,0 +1,44 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { getTrafficCollectorSettings, getServersApiPingSettings, getUptimeCollectorSettings, getInternetPathSettings } from "./collector-settings"
const base = "https://api.example.test"
const common = { enabled: false, intervalSec: 60, lastCollectedAt: null, lastDurationMs: 0, lastError: null, collectorRunning: false }
const retained = { ...common, retentionDays: 14 }
const uptime = { ...retained, resourcesEnabled: false, pingEnabled: true, speedEnabled: false, probeIntervalSec: 15, speedIntervalSec: 60 }
const json = (body: unknown) => new Response(JSON.stringify(body))
for (const [name, read, payload, path] of [
["traffic", getTrafficCollectorSettings, retained, "/api/traffic/settings"],
["REST ping", getServersApiPingSettings, common, "/api/servers-api-ping/settings"],
["uptime", getUptimeCollectorSettings, uptime, "/api/uptime/settings"],
["internet path", getInternetPathSettings, retained, "/api/internet-path/settings"],
] as const) {
test(name + ": exact GET preserves false/zero/null and PostgreSQL timestamp instant", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async (url, init) => { assert.equal(new URL(String(url)).pathname, path); assert.equal(init?.method, "GET"); return json(payload) }
const result = await read(base); assert.equal(result.enabled, false); assert.equal(result.lastDurationMs, 0); assert.equal(result.lastCollectedAt, null)
globalThis.fetch = async () => json({ ...payload, lastCollectedAt: "2026-10-01 00:00:00+00" }); assert.equal((await read(base)).lastCollectedAt, "2026-10-01T00:00:00.000Z")
} finally { globalThis.fetch = original }
})
test(name + ": malformed response is unknown without field defaults", async () => {
const original = globalThis.fetch
try {
for (const raw of [{}, { ...payload, enabled: undefined }, { ...payload, intervalSec: "60" }, { ...payload, intervalSec: 0 }, { ...payload, lastDurationMs: -1 }, { ...payload, lastCollectedAt: "bad" }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => read(base), /Настройки сборщика не подтверждены/) }
} finally { globalThis.fetch = original }
})
}
test("REST ping does not invent retention, uptime preserves separate flags and ignores nested scheduler metadata", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(common); assert.equal("retentionDays" in await getServersApiPingSettings(base), false)
globalThis.fetch = async () => json({ ...uptime, scheduler: { jobs: "malformed independent metadata" } }); const result = await getUptimeCollectorSettings(base); assert.equal(result.resourcesEnabled, false); assert.equal(result.pingEnabled, true); assert.equal(result.speedEnabled, false); assert.equal("scheduler" in result, false)
for (const key of ["resourcesEnabled", "pingEnabled", "speedEnabled", "probeIntervalSec", "speedIntervalSec"]) { globalThis.fetch = async () => json({ ...uptime, [key]: undefined }); await assert.rejects(() => getUptimeCollectorSettings(base)) }
} finally { globalThis.fetch = original }
})
test("retained collectors require actual retention and caller cancellation is preserved", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(common); await assert.rejects(() => getTrafficCollectorSettings(base)); await assert.rejects(() => getInternetPathSettings(base))
const controller = new AbortController(); controller.abort(); globalThis.fetch = async (_url, init) => { assert.equal(init?.signal?.aborted, true); throw controller.signal.reason }; await assert.rejects(() => getTrafficCollectorSettings(base, { signal: controller.signal }))
} finally { globalThis.fetch = original }
})
+54
View File
@@ -0,0 +1,54 @@
import { z } from "zod"
import { requestJson } from "./http-client"
const count = z.number().int().nonnegative().safe()
const timestamp = z.string().min(1).refine(value => Number.isFinite(Date.parse(value))).transform(value => new Date(value).toISOString())
const common = z.object({ enabled: z.boolean(), intervalSec: count.positive(), lastCollectedAt: timestamp.nullable(), lastDurationMs: count.nullable(), lastError: z.string().nullable(), collectorRunning: z.boolean().optional() })
const retained = common.extend({ retentionDays: count.positive() })
const uptime = retained.extend({ resourcesEnabled: z.boolean(), pingEnabled: z.boolean(), speedEnabled: z.boolean(), probeIntervalSec: count.positive(), speedIntervalSec: count.positive() })
async function settings<T>(baseUrl: string, path: string, schema: z.ZodType<T>, init?: RequestInit): Promise<T> {
const raw = await requestJson<unknown>(baseUrl, path, { ...init, method: "GET", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
const result = schema.safeParse(raw)
if (!result.success) throw new Error("Настройки сборщика не подтверждены: получен некорректный ответ сервера")
return result.data
}
export function getTrafficCollectorSettings(baseUrl: string, init?: RequestInit) {
return settings(baseUrl, "/api/traffic/settings", retained, init)
}
export function getServersApiPingSettings(baseUrl: string, init?: RequestInit) {
return settings(baseUrl, "/api/servers-api-ping/settings", common, init)
}
export function getUptimeCollectorSettings(baseUrl: string, init?: RequestInit) {
// Scheduler metadata is obtained independently through the scheduler API.
return settings(baseUrl, "/api/uptime/settings", uptime, init)
}
export function getInternetPathSettings(baseUrl: string, init?: RequestInit) {
return settings(baseUrl, "/api/internet-path/settings", retained, init)
}
const trafficPatch = z.object({ enabled: z.boolean().optional(), intervalSec: count.min(5).optional(), retentionDays: count.positive().optional() }).strict()
const restPatch = z.object({ enabled: z.boolean().optional(), intervalSec: count.min(10).optional() }).strict()
const uptimePatch = z.object({ resourcesEnabled: z.boolean().optional(), pingEnabled: z.boolean().optional(), speedEnabled: z.boolean().optional(), intervalSec: count.min(5).optional(), probeIntervalSec: count.min(5).optional(), speedIntervalSec: count.min(10).optional(), retentionDays: count.positive().optional() }).strict()
const internetPatch = z.object({ enabled: z.boolean().optional(), intervalSec: count.min(30).optional(), retentionDays: count.positive().optional() }).strict()
async function putSettings<T, P extends Record<string, unknown>>(baseUrl: string, path: string, payload: P, input: z.ZodType<P>, output: z.ZodType<T>, init?: RequestInit): Promise<T> {
const valid = input.safeParse(payload)
if (!valid.success || !Object.keys(valid.data).some(key => valid.data[key] !== undefined)) throw new Error("Укажите допустимые целые значения настроек сборщика")
const body = valid.data
const raw = await requestJson<unknown>(baseUrl, path, { ...init, method: "PUT", body: JSON.stringify(body), signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
const parsed = z.object({ ok: z.literal(true), settings: output }).safeParse(raw)
if (!parsed.success) throw new Error("Сохранение настроек не подтверждено. Обновите данные перед повторной попыткой")
const confirmed = parsed.data.settings as Record<string, unknown>
if (Object.entries(body).some(([key, value]) => value !== undefined && confirmed[key] !== value)) throw new Error("Сервер подтвердил другие настройки. Обновите данные перед повторной попыткой")
return parsed.data.settings
}
export function putTrafficCollectorSettings(baseUrl: string, payload: z.infer<typeof trafficPatch>, init?: RequestInit) {
return putSettings(baseUrl, "/api/traffic/settings", payload, trafficPatch, retained, init)
}
export function putServersApiPingSettings(baseUrl: string, payload: z.infer<typeof restPatch>, init?: RequestInit) {
return putSettings(baseUrl, "/api/servers-api-ping/settings", payload, restPatch, common, init)
}
export function putUptimeCollectorSettings(baseUrl: string, payload: z.infer<typeof uptimePatch>, init?: RequestInit) {
return putSettings(baseUrl, "/api/uptime/settings", payload, uptimePatch, uptime, init)
}
export function putInternetPathSettings(baseUrl: string, payload: z.infer<typeof internetPatch>, init?: RequestInit) {
return putSettings(baseUrl, "/api/internet-path/settings", payload, internetPatch, retained, init)
}
+11
View File
@@ -0,0 +1,11 @@
import { containersStateSchema, containerMutationAckSchema } from "@mmapp/contracts/containers"
import { requestJson } from "./http-client"
export async function readContainersState(baseUrl: string, options?: RequestInit) {
return containersStateSchema.parse(await requestJson<unknown>(baseUrl, "/api/containers/state", options))
}
export type ContainerAction = "start" | "stop" | "restart" | "remove"
export async function mutateContainer(baseUrl: string, serverId: string, rosId: string, action: ContainerAction, options?: RequestInit) {
if (!/^[1-9]\d*$/.test(serverId) || !Number.isSafeInteger(Number(serverId)) || !rosId.trim()) throw new Error("Неверный контекст контейнера")
return containerMutationAckSchema.parse(await requestJson<unknown>(baseUrl, `/api/servers/${encodeURIComponent(serverId)}/containers/${action}`, { ...options, method: "POST", body: JSON.stringify({ rosId }) }))
}
+27
View File
@@ -0,0 +1,27 @@
import {
eventListSchema,
listEventsQuerySchema,
type EventItem,
type ListEventsQuery,
} from "@mmapp/contracts/events"
import { requestJson } from "@/shared/api/http-client"
type ListEventsResponse = {
events: unknown
}
export async function listEvents(
baseUrl: string,
query: Partial<ListEventsQuery> = {},
): Promise<EventItem[]> {
const parsedQuery = listEventsQuerySchema.parse(query)
const search = new URLSearchParams()
search.set("limit", String(parsedQuery.limit))
if (parsedQuery.level) search.set("level", parsedQuery.level)
if (parsedQuery.sourceModule) search.set("sourceModule", parsedQuery.sourceModule)
if (parsedQuery.from) search.set("from", parsedQuery.from)
if (parsedQuery.to) search.set("to", parsedQuery.to)
const path = `/api/events?${search.toString()}`
const payload = await requestJson<ListEventsResponse>(baseUrl, path)
return eventListSchema.parse(payload.events)
}
+12
View File
@@ -0,0 +1,12 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {getEvoBgpSettings,putEvoBgpSettings,testEvoBgpConnection} from "./evobgp-settings"
const settings={baseUrl:"https://evo.example.test",enabled:false,secretConfigured:false},base="http://fixture.test"
const json=(value:unknown)=>new Response(JSON.stringify(value))
test("Evo settings require known boolean values and no returned secret",async()=>{const original=globalThis.fetch;try{globalThis.fetch=async()=>json(settings);assert.deepEqual(await getEvoBgpSettings(base),settings);for(const bad of [{}, {...settings,enabled:undefined},{...settings,secretConfigured:0},{...settings,apiKey:"fixture-only"},{...settings,baseUrl:"https://evo.test/v1"}]){globalThis.fetch=async()=>json(bad);await assert.rejects(getEvoBgpSettings(base),/не подтверждены/)}}finally{globalThis.fetch=original}})
test("Evo PUT normalizes explicit fields and requires matching receipt/secret flag without echoing key",async()=>{const original=globalThis.fetch;try{let calls=0;globalThis.fetch=async(_url,init)=>{calls++;assert.deepEqual(JSON.parse(String(init?.body)),{baseUrl:settings.baseUrl,enabled:false,apiKey:"fixture-only"});return json({...settings,ok:true,secretConfigured:true,savedFields:["baseUrl","enabled","apiKey"]})};await putEvoBgpSettings(base,{baseUrl:"evo.example.test/",enabled:false,apiKey:"Bearer fixture-only"});assert.equal(calls,1)
for(const bad of [{...settings},{...settings,ok:false,savedFields:["enabled"]},{...settings,ok:true,enabled:true,savedFields:["enabled"]},{...settings,ok:true,savedFields:["enabled","enabled"]}]){globalThis.fetch=async()=>json(bad);await assert.rejects(putEvoBgpSettings(base,{enabled:false}),/не подтверждено/)}
for(const patch of [{},{baseUrl:"https://evo.test/v1"},{baseUrl:"ftp://evo.test"},{apiKey:"fixture\nonly"}]){calls=0;globalThis.fetch=async()=>{calls++;return json({})};await assert.rejects(putEvoBgpSettings(base,patch));assert.equal(calls,0)}
globalThis.fetch=async()=>json({...settings,ok:true,savedFields:["apiKey"]});assert.equal((await putEvoBgpSettings(base,{apiKey:null})).secretConfigured,false)
}finally{globalThis.fetch=original}})
test("Evo test requires literal confirmation and never retries unknown POST",async()=>{const original=globalThis.fetch;try{globalThis.fetch=async()=>json({ok:true});await testEvoBgpConnection(base);for(const bad of [{},{ok:false},{ok:"true"}]){let calls=0;globalThis.fetch=async()=>{calls++;return json(bad)};await assert.rejects(testEvoBgpConnection(base),/не подтверждено/);assert.equal(calls,1)}}finally{globalThis.fetch=original}})
+24
View File
@@ -0,0 +1,24 @@
import { evoBgpSettingsSchema,evoBgpSettingsPatchSchema,evoBgpSaveReceiptSchema,evoBgpTestDraftSchema,type EvoBgpSavePayload,type EvoBgpTestDraft } from "@mmapp/contracts/evobgp"
import {requestJson} from "./http-client"
import {confirmEvoBgpConnection} from "@/lib/evobgp-test-receipt"
function initWithTimeout(init?:RequestInit):RequestInit {return {...init,signal:AbortSignal.any([...(init?.signal?[init.signal]:[]),AbortSignal.timeout(30_000)])}}
export async function getEvoBgpSettings(base:string,init?:RequestInit){
const parsed=evoBgpSettingsSchema.safeParse(await requestJson<unknown>(base,"/api/evobgp/settings",{...initWithTimeout(init),method:"GET"}))
if(!parsed.success)throw new Error("Настройки EvoBGP не подтверждены сервером")
return parsed.data
}
export async function putEvoBgpSettings(base:string,patch:EvoBgpSavePayload,init?:RequestInit){
const input=evoBgpSettingsPatchSchema.safeParse(patch)
if(!input.success)throw new Error(input.error.issues[0]?.message??"Укажите допустимые настройки EvoBGP")
const body=input.data
const parsed=evoBgpSaveReceiptSchema.safeParse(await requestJson<unknown>(base,"/api/evobgp/settings",{...initWithTimeout(init),method:"PUT",body:JSON.stringify(body)}))
if(!parsed.success)throw new Error("Сохранение EvoBGP не подтверждено сервером")
const data=parsed.data,fields=Object.keys(body).filter(field=>(body as Record<string,unknown>)[field]!==undefined)
if(data.savedFields.length!==fields.length||fields.some(field=>!data.savedFields.includes(field as typeof data.savedFields[number]))||body.baseUrl!==undefined&&data.baseUrl!==body.baseUrl||body.enabled!==undefined&&data.enabled!==body.enabled||body.apiKey!==undefined&&data.secretConfigured!==Boolean(body.apiKey))throw new Error("Сохранение EvoBGP не подтверждено: сервер вернул другие настройки")
return data
}
export async function testEvoBgpConnection(base:string,draft:EvoBgpTestDraft={},init?:RequestInit):Promise<void>{
const input=evoBgpTestDraftSchema.safeParse(draft)
if(!input.success)throw new Error(input.error.issues[0]?.message??"Укажите допустимые параметры EvoBGP")
confirmEvoBgpConnection(await requestJson<unknown>(base,"/api/evobgp/test",{...initWithTimeout(init),method:"POST",body:JSON.stringify(input.data)}))
}
+36
View File
@@ -0,0 +1,36 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { getGeoipStatus, putGeoipSettings, runGeoipUpdateNow } from "./geoip"
const base = "https://api.example.test"
const status = { ready: false, countryLoaded: false, asnLoaded: false, countryFile: "country.mmdb", asnFile: "asn.mmdb", dir: "geoip", running: false, settings: { enabled: false, updateIntervalSec: 604800, lastCheckAt: null, lastSuccessAt: null, lastError: null, countryBuildAt: null, asnBuildAt: null, updatedAt: "2026-10-01T00:00:00Z" } }
const snapshot = { v: 1, job: "geoip_update", sampledAt: "2026-10-01T00:00:00Z", checked: 2, downloaded: 1, skippedUnchanged: 0, bytes: 0, errors: ["ASN unavailable"] }
const json = (body: unknown) => new Response(JSON.stringify(body))
test("malformed GeoIP status is rejected before UI state; false and nullable metadata stay explicit", async () => {
const original = globalThis.fetch
try {
for (const raw of [{}, null, { ...status, settings: undefined }, { ...status, settings: {} }, { ...status, ready: true }, { ...status, settings: { ...status.settings, updatedAt: "bad" } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getGeoipStatus(base), /Данные GeoIP не подтверждены/) }
globalThis.fetch = async () => json(status); const result = await getGeoipStatus(base); assert.equal(result.ready, false); assert.equal(result.settings.enabled, false); assert.equal(result.settings.lastSuccessAt, null)
} finally { globalThis.fetch = original }
})
test("PUT validates input and confirms sent settings rather than trusting ok", async () => {
const original = globalThis.fetch
try {
let calls = 0; globalThis.fetch = async () => { calls++; return json({}) }; await assert.rejects(() => putGeoipSettings(base, { updateIntervalSec: 0 })); assert.equal(calls, 0)
for (const raw of [{}, { ok: false, status }, { ok: true, status: { ...status, settings: { ...status.settings, enabled: true } } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => putGeoipSettings(base, { enabled: false })) }
globalThis.fetch = async () => json({ ok: true, status }); assert.equal((await putGeoipSettings(base, { enabled: false })).status.settings.enabled, false)
} finally { globalThis.fetch = original }
})
test("partial update retains downloaded data and errors; contradictory, negative or empty success is rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json({ ok: false, snapshot }); const result = await runGeoipUpdateNow(base); assert.equal(result.ok, false); assert.equal(result.snapshot.downloaded, 1); assert.equal(result.snapshot.bytes, 0)
for (const raw of [{}, { ok: true, snapshot }, { ok: false, snapshot: { ...snapshot, checked: -1 } }, { ok: false, snapshot: { ...snapshot, downloaded: 3 } }, { ok: false, snapshot: { ...snapshot, v: 2 } }, { ok: false, snapshot: { ...snapshot, sampledAt: "bad" } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => runGeoipUpdateNow(base)) }
} finally { globalThis.fetch = original }
})
test("mutation has one attempt on lost acknowledgment and carries caller cancellation", async () => {
const original = globalThis.fetch
try {
let calls = 0; globalThis.fetch = async () => { calls++; throw Error("lost receipt") }; await assert.rejects(() => runGeoipUpdateNow(base), /lost receipt/); assert.equal(calls, 1)
const controller = new AbortController(); controller.abort(); globalThis.fetch = async (_url, init) => { assert.equal(init?.signal?.aborted, true); throw controller.signal.reason }; await assert.rejects(() => getGeoipStatus(base, { signal: controller.signal }))
} finally { globalThis.fetch = original }
})
+25
View File
@@ -0,0 +1,25 @@
import { z } from "zod"
import { geoipSettingsPatchSchema, geoipStatusDtoSchema, geoipUpdateSnapshotDtoSchema, type GeoipSettingsPatch } from "@mmapp/contracts/geoip"
import { requestJson } from "@/shared/api/http-client"
const statusSchema = geoipStatusDtoSchema.refine(status => status.ready === (status.countryLoaded && status.asnLoaded) && [status.settings.updatedAt, status.settings.lastCheckAt, status.settings.lastSuccessAt, status.settings.countryBuildAt, status.settings.asnBuildAt].every(value => value === null || Number.isFinite(Date.parse(value))), "Несогласованная готовность или время GeoIP")
const updateSchema = z.object({ ok: z.boolean(), snapshot: geoipUpdateSnapshotDtoSchema }).refine(response => response.snapshot.v === 1 && Number.isFinite(Date.parse(response.snapshot.sampledAt)) && response.snapshot.checked >= 0 && response.snapshot.downloaded >= 0 && response.snapshot.skippedUnchanged >= 0 && response.snapshot.downloaded + response.snapshot.skippedUnchanged <= response.snapshot.checked && response.ok === (!response.snapshot.fatalError && response.snapshot.errors.length === 0), "Несогласованный результат GeoIP")
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Данные GeoIP не подтверждены: получен некорректный ответ сервера")
return parsed.data
}
async function json(baseUrl: string, path: string, init?: RequestInit) {
return requestJson<unknown>(baseUrl, path, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
}
export async function getGeoipStatus(baseUrl: string, init?: RequestInit) {
return confirmed(statusSchema, await json(baseUrl, "/api/geoip", { ...init, method: "GET" }))
}
export async function putGeoipSettings(baseUrl: string, patch: GeoipSettingsPatch, init?: RequestInit) {
const body = geoipSettingsPatchSchema.strict().parse(patch)
const response = confirmed(z.object({ ok: z.literal(true), status: statusSchema }), await json(baseUrl, "/api/geoip", { ...init, method: "PUT", body: JSON.stringify(body) }))
for (const field of ["enabled", "updateIntervalSec"] as const) if (body[field] !== undefined && body[field] !== response.status.settings[field]) throw new Error("Сохранение настроек GeoIP не подтверждено: ответ отличается от отправленных значений")
return response
}
export async function runGeoipUpdateNow(baseUrl: string, init?: RequestInit) {
return confirmed(updateSchema, await json(baseUrl, "/api/geoip/update", { ...init, method: "POST" }))
}
+124
View File
@@ -0,0 +1,124 @@
import { configuredBackendUrl } from "@/lib/backend-url"
import {
getToken,
isAuthEnabled,
redirectToPortalLogin,
redirectToPortalLoginInteractive,
} from "@/lib/auth"
export class ApiClientError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly payload?: unknown,
) {
super(message)
this.name = "ApiClientError"
}
}
function trimBaseUrl(baseUrl: string): string {
return baseUrl.replace(/\/$/, "")
}
/** Absolute or same-origin-relative URL for backend API paths. */
export function resolveApiUrl(baseUrl: string, path: string): string {
if (path.startsWith("/") && configuredBackendUrl().kind === "same-origin") {
return path
}
// Safety: never call browser localhost when the UI is served from a remote host
if (typeof window !== "undefined") {
const host = window.location.hostname
const remoteUi = host !== "localhost" && host !== "127.0.0.1"
const baseIsLocal =
/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(trimBaseUrl(baseUrl))
if (remoteUi && (baseIsLocal || !baseUrl.trim())) {
return path.startsWith("/") ? path : `/${path}`
}
}
return trimBaseUrl(baseUrl) + path
}
/** Attach portal JWT when present. */
export function withAuthHeaders(init?: HeadersInit): Headers {
const headers = new Headers(init)
const token = typeof window !== "undefined" ? getToken() : null
if (token && !headers.has("Authorization")) {
headers.set("Authorization", `Bearer ${token}`)
}
return headers
}
function handleUnauthorized(): never {
if (typeof window !== "undefined" && isAuthEnabled()) {
const ok = redirectToPortalLogin()
if (!ok) redirectToPortalLoginInteractive()
}
throw new ApiClientError("Unauthorized", 401)
}
async function parseErrorMessage(res: Response): Promise<string> {
const payload = await res.json().catch(() => undefined)
if (
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
) {
return (payload as { error: string }).error
}
return res.statusText || `HTTP ${res.status}`
}
export async function requestJson<T>(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<T> {
const hasBody = init?.body != null
const headers = withAuthHeaders(init?.headers)
if (hasBody && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json")
}
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (res.status === 204) return undefined as T
const payload = await res.json().catch(() => undefined)
if (!res.ok) {
const msg =
typeof payload === "object" &&
payload !== null &&
"error" in payload &&
typeof (payload as { error?: unknown }).error === "string"
? (payload as { error: string }).error
: res.statusText
throw new ApiClientError(msg, res.status, payload)
}
return payload as T
}
/** Binary/download endpoints (backup, backup file) with the same auth + URL rules. */
export async function requestBlob(
baseUrl: string,
path: string,
init?: RequestInit,
): Promise<Response> {
const headers = withAuthHeaders(init?.headers)
const res = await fetch(resolveApiUrl(baseUrl, path), {
...init,
headers,
})
if (res.status === 401) handleUnauthorized()
if (!res.ok) {
throw new ApiClientError(await parseErrorMessage(res), res.status)
}
return res
}
+105
View File
@@ -0,0 +1,105 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { exportIpsecCertByName, exportIpsecUserCert, type IpsecExistingCertExportOptions } from "./ipsec"
const base = "https://fixture.example.test"
const options: IpsecExistingCertExportOptions = { privateKeyExport: true, caPrivateKeyExport: false, requestKey: "11111111-1111-4111-8111-111111111111" }
const passphrase = " synthetic-passphrase "
const bundle = { user: "Renamed label", filename: "fixture.p12", contentB64: "YQ==", passphrase }
test("existing exports reject missing/invalid explicit intent before fetch", async () => {
const original = globalThis.fetch
let calls = 0
globalThis.fetch = async () => { calls++; throw Error("Unexpected fetch") }
try {
for (const invalid of [undefined, {}, { ...options, privateKeyExport: false }, { ...options, caPrivateKeyExport: undefined }, { ...options, requestKey: "invalid" }, { ...options, requestKey: undefined }, { ...options, extra: true }]) {
const intent = invalid as IpsecExistingCertExportOptions
await assert.rejects(() => exportIpsecUserCert(base, "1", "identity:*1", passphrase, intent))
await assert.rejects(() => exportIpsecCertByName(base, "1", "cert", passphrase, intent))
}
await assert.rejects(() => exportIpsecUserCert(base, "1", "identity:*1", "short", options))
assert.equal(calls, 0)
} finally { globalThis.fetch = original }
})
test("client export preserves native identity selector, raw passphrase, flags and caller UUID", async () => {
const original = globalThis.fetch
let calls = 0
globalThis.fetch = async (url, init) => {
calls++
assert.equal(new URL(String(url)).pathname.endsWith("/identity%3A*1%2F2/cert"), true)
assert.equal(init?.method, "POST")
const headers = new Headers(init?.headers), body = JSON.parse(String(init?.body))
assert.equal(headers.get("Idempotency-Key") === options.requestKey, true)
assert.equal(headers.get("Content-Type"), "application/json")
// Compare as booleans so assertion failures cannot print private material.
assert.equal(body.passphrase === passphrase, true)
assert.equal(body.privateKeyExport, true); assert.equal(body.caPrivateKeyExport, false)
assert.equal(Object.hasOwn(body, "requestKey"), false); assert.equal(Object.hasOwn(body, "name"), false)
return new Response(JSON.stringify(bundle))
}
try {
const result = await exportIpsecUserCert(base, "1", "identity:*1/2", passphrase, options)
assert.equal(result.passphrase === passphrase, true); assert.equal(calls, 1)
} finally { globalThis.fetch = original }
})
test("by-name export supports client/server/foreign/CA targets and explicit CA intent", async () => {
const original = globalThis.fetch
let expectedName = "", expectedCa = false, calls = 0
globalThis.fetch = async (url, init) => {
calls++
assert.equal(new URL(String(url)).pathname, "/api/ipsec/certs/1/export")
const body = JSON.parse(String(init?.body))
assert.equal(body.name === expectedName, true); assert.equal(body.passphrase === passphrase, true)
assert.equal(body.privateKeyExport, true); assert.equal(body.caPrivateKeyExport, expectedCa)
assert.equal(new Headers(init?.headers).get("Idempotency-Key") === options.requestKey, true)
return new Response(JSON.stringify(bundle))
}
try {
for (const [name, ca] of [["client", false], ["server", false], ["foreign", false], ["ca", true]] as const) {
expectedName = name; expectedCa = ca
await exportIpsecCertByName(base, "1", name, passphrase, { ...options, caPrivateKeyExport: ca })
}
assert.equal(calls, 4)
} finally { globalThis.fetch = original }
})
test("failed, unknown and malformed responses never retry or allocate another request UUID", async () => {
const original = globalThis.fetch
try {
for (const mode of ["network", "unknown", "malformed"] as const) {
let calls = 0
globalThis.fetch = async (_url, init) => {
calls++
assert.equal(new Headers(init?.headers).get("Idempotency-Key") === options.requestKey, true)
if (mode === "network") throw Error("Synthetic transport failure")
if (mode === "unknown") return new Response(JSON.stringify({ error: "Результат неизвестен", state: "unknown", needsReview: true }), { status: 502 })
return new Response(JSON.stringify({ ...bundle, contentB64: "invalid!" }))
}
await assert.rejects(() => exportIpsecUserCert(base, "1", "identity:*1", passphrase, options))
assert.equal(calls, 1)
await assert.rejects(() => exportIpsecCertByName(base, "1", "client", passphrase, options))
assert.equal(calls, 2)
}
} finally { globalThis.fetch = original }
})
test("closing an export can abort its one fetch without changing explicit intent", async () => {
const original = globalThis.fetch, controller = new AbortController()
let calls = 0
globalThis.fetch = async (_url, init) => {
calls++
assert.equal(init?.signal === controller.signal, true)
assert.equal(new Headers(init?.headers).get("Idempotency-Key") === options.requestKey, true)
return new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true })
})
}
try {
const pending = exportIpsecUserCert(base, "1", "identity:*1", passphrase, options, { signal: controller.signal })
controller.abort()
await assert.rejects(pending, error => error instanceof DOMException && error.name === "AbortError")
assert.equal(calls, 1)
} finally { globalThis.fetch = original }
})
+242
View File
@@ -0,0 +1,242 @@
import { ipsecListResponseSchema, ipsecServerSummaryDtoSchema, ipsecUserCreatedSchema, ipsecCertBundleSchema, ipsecExistingCertificateExportIntentSchema, ipsecExistingCertificateExportRequestKeySchema } from "@mmapp/contracts/ipsec"
import { requestJson } from "@/shared/api/http-client"
import { z } from "zod"
import type {
IpsecCertBundle,
IpsecInitRequest,
IpsecListResponse,
IpsecPeerPatch,
IpsecServerSummaryDto,
IpsecUserCreateRequest,
IpsecUserCreated,
IpsecUserPatch,
} from "@mmapp/contracts/ipsec"
export async function listIpsec(
baseUrl: string,
opts?: { serverId?: string },
): Promise<IpsecListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
const suffix = q.size > 0 ? `?${q.toString()}` : ""
const response = await requestJson<unknown>(baseUrl, `/api/ipsec${suffix}`, { method: "GET" })
const result = ipsecListResponseSchema.safeParse(response)
if (!result.success) throw new Error("Некорректный ответ загрузки IPsec")
const data = result.data
const serverIds = data.servers.map(s => s.serverId)
const clientIds = data.clients.map(c => c.id)
const failedIds = (data.failures ?? []).map(f => f.serverId)
if (new Set(serverIds).size !== serverIds.length || new Set(clientIds).size !== clientIds.length ||
new Set(failedIds).size !== failedIds.length || failedIds.some(id => !id.trim()) ||
data.servers.some(s => s.peers.some(p => p.serverId !== s.serverId) ||
new Set(s.peers.map(p => p.id)).size !== s.peers.length ||
[s.peer, s.pool, s.sharedModeConfig].some(child => child && child.serverId !== s.serverId) ||
new Set((s.certs ?? []).map(c => c.name)).size !== (s.certs ?? []).length) || serverIds.some(id => failedIds.includes(id)) ||
data.clients.some(c => failedIds.includes(c.serverId)) ||
(opts?.serverId && [...serverIds, ...failedIds, ...data.clients.map(c => c.serverId)].some(id => id !== opts.serverId))) {
throw new Error("Несогласованный ответ загрузки IPsec")
}
return data
}
export async function initIpsecServer(
baseUrl: string,
body: IpsecInitRequest,
): Promise<IpsecServerSummaryDto> {
const response = await requestJson<unknown>(baseUrl, "/api/ipsec/server/init", {
method: "POST",
body: JSON.stringify(body),
})
const result = ipsecServerSummaryDtoSchema.safeParse(response)
if (!result.success || result.data.serverId !== body.serverId || !result.data.initialized) {
throw new Error("Сервер не подтвердил инициализацию IKEv2")
}
return result.data
}
export async function deleteIpsecServer(
baseUrl: string,
serverId: string,
opts?: { removeCertificates?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.removeCertificates === false) q.set("removeCertificates", "false")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/server/${encodeURIComponent(serverId)}${suffix}`,
{ method: "DELETE" },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
return result
}
export async function createIpsecUser(
baseUrl: string,
body: IpsecUserCreateRequest,
): Promise<IpsecUserCreated> {
const response = await requestJson<unknown>(baseUrl, "/api/ipsec/users", {
method: "POST",
body: JSON.stringify(body),
})
const result = ipsecUserCreatedSchema.safeParse(response)
if (!result.success || result.data.client.serverId !== String(body.serverId) || result.data.client.name !== body.name.trim() || result.data.client.authMethod !== (body.authMethod ?? "certificate")) {
throw new Error("Сервер не подтвердил создание клиента")
}
return result.data
}
export async function patchIpsecUser(
baseUrl: string,
serverId: string,
clientId: string,
patch: IpsecUserPatch,
): Promise<{ ok: boolean }> {
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}`,
{ method: "PATCH", body: JSON.stringify(patch) },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение клиента")
return result
}
export async function deleteIpsecUser(
baseUrl: string,
serverId: string,
clientId: string,
opts?: { removeCertificate?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.removeCertificate === false) q.set("removeCertificate", "false")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}${suffix}`,
{ method: "DELETE" },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
return result
}
function parseExportBundle(response: unknown): IpsecCertBundle {
const result = ipsecCertBundleSchema.safeParse(response)
if (!result.success || !result.data.filename.trim() || !result.data.user.trim() ||
!result.data.passphrase || result.data.passphrase.length < 8 ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(result.data.contentB64) ||
!result.data.contentB64) {
throw new Error("Некорректный ответ экспорта сертификата")
}
return result.data
}
const existingCertExportOptionsSchema = ipsecExistingCertificateExportIntentSchema.extend({
caPrivateKeyExport: z.boolean(),
requestKey: ipsecExistingCertificateExportRequestKeySchema,
}).strict()
export type IpsecExistingCertExportOptions = z.infer<typeof existingCertExportOptionsSchema>
function existingCertExportIntent(passphrase: string, options: IpsecExistingCertExportOptions) {
const parsed = existingCertExportOptionsSchema.safeParse(options)
if (!parsed.success || !z.string().min(8).max(16384).safeParse(passphrase).success) {
throw new Error("Экспорт требует явного подтверждения приватного ключа и UUID запроса")
}
return parsed.data
}
export async function exportIpsecUserCert(
baseUrl: string,
serverId: string,
clientId: string,
passphrase: string,
options: IpsecExistingCertExportOptions,
control?: { signal?: AbortSignal },
): Promise<IpsecCertBundle> {
const intent = existingCertExportIntent(passphrase, options)
const response = await requestJson<unknown>(
baseUrl,
`/api/ipsec/users/${encodeURIComponent(serverId)}/${encodeURIComponent(clientId)}/cert`,
{ method: "POST", signal: control?.signal, headers: { "Idempotency-Key": intent.requestKey }, body: JSON.stringify({ passphrase, privateKeyExport: intent.privateKeyExport, caPrivateKeyExport: intent.caPrivateKeyExport }) },
)
return parseExportBundle(response)
}
export async function exportIpsecCertByName(
baseUrl: string,
serverId: string,
name: string,
passphrase: string,
options: IpsecExistingCertExportOptions,
control?: { signal?: AbortSignal },
): Promise<IpsecCertBundle> {
const intent = existingCertExportIntent(passphrase, options)
const response = await requestJson<unknown>(
baseUrl,
`/api/ipsec/certs/${encodeURIComponent(serverId)}/export`,
{ method: "POST", signal: control?.signal, headers: { "Idempotency-Key": intent.requestKey }, body: JSON.stringify({ name, passphrase, privateKeyExport: intent.privateKeyExport, caPrivateKeyExport: intent.caPrivateKeyExport }) },
)
return parseExportBundle(response)
}
export async function restoreIpsecRevision(
baseUrl: string,
revisionId: string,
serverId: string,
): Promise<{ ok: boolean }> {
return requestJson<{ ok: boolean }>(baseUrl, `/api/ipsec/revisions/${encodeURIComponent(revisionId)}/restore`, {
method: "POST",
body: JSON.stringify({ serverId }),
})
}
export async function patchIpsecPeer(
baseUrl: string,
serverId: string,
peerId: string,
patch: IpsecPeerPatch,
): Promise<{ ok: boolean }> {
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}`,
{ method: "PATCH", body: JSON.stringify(patch) },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил сохранение peer")
return result
}
export async function deleteIpsecPeer(
baseUrl: string,
serverId: string,
peerId: string,
opts?: { force?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.force) q.set("force", "true")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(peerId)}${suffix}`,
{ method: "DELETE" },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
return result
}
export async function deleteIpsecCert(
baseUrl: string,
serverId: string,
name: string,
opts?: { force?: boolean },
): Promise<{ ok: boolean }> {
const q = new URLSearchParams()
if (opts?.force) q.set("force", "true")
const suffix = q.size > 0 ? `?${q.toString()}` : ""
const result = await requestJson<{ ok: boolean }>(
baseUrl,
`/api/ipsec/certs/${encodeURIComponent(serverId)}${suffix}`,
{ method: "DELETE", body: JSON.stringify({ name }) },
)
if (result?.ok !== true) throw new Error("Сервер не подтвердил удаление")
return result
}
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
import { logEntrySchema, logPageSchema, logSettingsSchema, logSettingsPatchSchema, logSummarySchema } from "@mmapp/contracts"
import { requestBlob, requestJson } from "./http-client"
const timedSignal = (signal?: AbortSignal, ms = 30000) => AbortSignal.any([...(signal ? [signal] : []), AbortSignal.timeout(ms)])
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Ответ сервера журнала не подтверждён: некорректные данные")
return parsed.data
}
export async function getLogs(base: string, params: URLSearchParams, signal?: AbortSignal) {
const result = confirmed(logPageSchema, await requestJson(base, "/api/logs?" + params, { signal: timedSignal(signal) }))
const server = params.get("serverId")
if (server && result.entries.some(row => String(row.serverId) !== server)) throw new Error("Журнал относится к другому устройству")
return result
}
export async function getLogSummary(base: string, params: URLSearchParams, signal?: AbortSignal) {
return confirmed(logSummarySchema, await requestJson(base, "/api/logs/summary?" + params, { signal: timedSignal(signal) }))
}
export async function getLogContext(base: string, id: string, signal?: AbortSignal) {
return confirmed(z.object({ entry: logEntrySchema, before: z.array(logEntrySchema), after: z.array(logEntrySchema) }), await requestJson(base, `/api/logs/${encodeURIComponent(id)}/context`, { signal: timedSignal(signal) }))
}
export async function getLogSettings(base: string, signal?: AbortSignal) { return confirmed(logSettingsSchema, await requestJson(base, "/api/logs/settings", { signal: timedSignal(signal) })) }
export async function putLogSettings(base: string, patch: z.infer<typeof logSettingsPatchSchema>, signal?: AbortSignal) {
const input = logSettingsPatchSchema.parse(patch)
const result = confirmed(z.object({ ok: z.literal(true), settings: logSettingsSchema }), await requestJson(base, "/api/logs/settings", { method: "PUT", body: JSON.stringify(input), signal: timedSignal(signal) }))
if (Object.entries(input).some(([key, value]) => result.settings[key as keyof typeof result.settings] !== value)) throw new Error("Сервер подтвердил другие настройки; обновите данные")
return result.settings
}
export async function exportLogs(base: string, params: URLSearchParams, format: "csv" | "json", signal?: AbortSignal) {
const query = new URLSearchParams(params); query.set("format", format)
const res = await requestBlob(base, "/api/logs/export?" + query, { signal: timedSignal(signal, 60000) })
const blob = await res.blob(), url = URL.createObjectURL(blob), anchor = document.createElement("a")
anchor.href = url; anchor.download = `router-logs.${format}`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000)
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { downloadHumanArtifact, humanArtifactError, humanArtifactReceipt } from "./mcp-artifact"
const id = "fa58fa40-4bcb-4616-9316-cb69463b3150", path = `/api/mcp/artifacts/${id}`
const receipt = { artifactId: id, humanDownloadOnly: true, downloadPath: path }
test("artifact paths come exclusively from a UUID and a matching structured receipt", () => {
assert.deepEqual(humanArtifactReceipt(receipt), { artifactId: id, path })
for (const invalid of [null, path, { ...receipt, artifactId: "../secrets" }, { ...receipt, artifactId: id + "?token=secret" }, { ...receipt, humanDownloadOnly: false }, ...["https://unsafe.test/", "//unsafe.test/", "javascript:alert(1)", path + "?token=secret", path + "/../x"].map(downloadPath => ({ ...receipt, downloadPath }))]) assert.equal(humanArtifactReceipt(invalid), null)
})
test("binary download uses the portal JWT, no redirects, temporary Blob URL and safe errors", async () => {
const globals = ["window", "document", "localStorage", "setTimeout"] as const
const descriptors = globals.map(name => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const)
const originalFetch = globalThis.fetch, originalCreate = URL.createObjectURL, originalRevoke = URL.revokeObjectURL
let token: string | null = "header." + btoa(JSON.stringify({ sub: "human", apps: ["mm"], exp: 4102444800 })) + ".signature"
let clicks = 0, removed = 0, revoked = 0, requests = 0
const anchor = { href: "", download: "", click: () => { clicks++ }, remove: () => { removed++ } }
try {
Object.defineProperty(globalThis, "window", { configurable: true, value: { location: { hostname: "localhost" } } })
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: { getItem: () => token, removeItem: () => { token = null } } })
Object.defineProperty(globalThis, "document", { configurable: true, value: { createElement: () => anchor, body: { appendChild: () => undefined } } })
Object.defineProperty(globalThis, "setTimeout", { configurable: true, value: (callback: () => void) => { callback(); return 0 } })
URL.createObjectURL = blob => { assert.ok(blob instanceof Blob); assert.equal(blob.size, 4); return "blob:temporary" }
URL.revokeObjectURL = url => { assert.equal(url, "blob:temporary"); revoked++ }
globalThis.fetch = async (url, init) => {
requests++
assert.ok(String(url).endsWith(path)); assert.equal(new Headers(init?.headers).get("Authorization"), `Bearer ${token}`)
assert.equal(init?.redirect, "error"); assert.equal(init?.cache, "no-store")
return new Response(new Uint8Array([0, 255, 128, 1]), { headers: { "Content-Disposition": 'attachment; filename="backup.rsc"' } })
}
await downloadHumanArtifact("http://localhost:8000", receipt)
assert.equal(anchor.href, "blob:temporary"); assert.equal(anchor.download, "backup.rsc")
assert.equal(clicks, 1); assert.equal(removed, 1); assert.equal(revoked, 1)
const cancelled = new AbortController(); cancelled.abort()
await assert.rejects(downloadHumanArtifact("http://localhost:8000", receipt, cancelled.signal)); assert.equal(clicks, 1)
for (const status of [403, 404, 409, 500]) {
globalThis.fetch = async () => new Response(JSON.stringify({ error: "NEVER_RENDER_SECRET" }), { status })
await assert.rejects(downloadHumanArtifact("http://localhost:8000", receipt), error => !humanArtifactError(error).includes("NEVER_RENDER_SECRET"))
}
token = "mmcp_bearer_not_a_portal_JWT"
globalThis.fetch = async () => { throw Error("must not request") }
await assert.rejects(downloadHumanArtifact("http://localhost:8000", receipt), error => humanArtifactError(error).includes("Войдите в портал"))
token = null
await assert.rejects(downloadHumanArtifact("http://localhost:8000", receipt))
assert.equal(requests, 2)
} finally {
globalThis.fetch = originalFetch; URL.createObjectURL = originalCreate; URL.revokeObjectURL = originalRevoke
for (const [name, descriptor] of descriptors) { if (descriptor) Object.defineProperty(globalThis, name, descriptor); else Reflect.deleteProperty(globalThis, name) }
}
})
+56
View File
@@ -0,0 +1,56 @@
import { z } from "zod"
import { getClaims, getToken } from "@/lib/auth"
import { ApiClientError, requestBlob } from "./http-client"
const receiptSchema = z.object({
artifactId: z.string().uuid(),
humanDownloadOnly: z.literal(true),
downloadPath: z.string().optional(),
})
/** Only structured receipts qualify; supplied paths never become navigation targets. */
export function humanArtifactReceipt(value: unknown) {
const parsed = receiptSchema.safeParse(value)
if (!parsed.success) return null
const path = `/api/mcp/artifacts/${parsed.data.artifactId}`
if (parsed.data.downloadPath !== undefined && parsed.data.downloadPath !== path) return null
return { artifactId: parsed.data.artifactId, path }
}
export async function downloadHumanArtifact(base: string, receipt: unknown, signal?: AbortSignal) {
const artifact = humanArtifactReceipt(receipt)
if (!artifact) throw new Error("Некорректная квитанция файла")
const claims = getClaims()
if (!getToken() || !claims?.sub || !claims.apps?.includes("mm")) {
throw new ApiClientError("Войдите в портал для скачивания файла", 401)
}
const response = await requestBlob(base, artifact.path, { signal, cache: "no-store", redirect: "error" })
const blob = await response.blob()
signal?.throwIfAborted()
const disposition = response.headers.get("Content-Disposition") ?? ""
const suggested = /filename="([a-zA-Z0-9._-]{1,200})"/.exec(disposition)?.[1]
const filename = suggested && suggested !== "." && suggested !== ".." ? suggested : `mcp-${artifact.artifactId}.bin`
const url = URL.createObjectURL(blob)
const link = document.createElement("a")
try {
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
} finally {
link.remove()
// Let the browser begin consuming the download before releasing the in-memory file.
setTimeout(() => URL.revokeObjectURL(url), 1000)
}
}
export function humanArtifactError(error: unknown): string {
if (error instanceof ApiClientError) {
if (error.status === 401) return "Войдите в портал для скачивания файла."
if (error.status === 403) return "Доступ к файлу отозван или файл принадлежит другому пользователю."
if (error.status === 404 || error.status === 410) return "Файл отсутствует или срок хранения истёк. Подготовьте новую выгрузку."
if (error.status === 409) return "Файл изменился или больше не соответствует квитанции. Подготовьте новую выгрузку."
}
// Do not render response bodies, filenames, credentials, or binary content as errors.
return "Не удалось скачать файл. Проверьте соединение и повторите попытку."
}
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {mcpPollStorageCapacitySchema} from "@mmapp/contracts/mcp"
import {mcpOverviewSchema} from "./mcp"
const capacity={status:"observed",owner:{used:8,limit:8,remaining:0},global:{exhausted:true,usage:{used:128,limit:128,remaining:0}},retentionDays:14}
test("poll retained capacity validates full counters and hides non-admin global usage",()=>{
assert.deepEqual(mcpPollStorageCapacitySchema.parse(capacity),capacity)
const privateView={...capacity,global:{exhausted:true,usage:null}}
assert.deepEqual(mcpPollStorageCapacitySchema.parse(privateView),privateView)
for(const bad of [{...capacity,owner:{used:8,limit:8,remaining:1}},{...capacity,global:{exhausted:false,usage:capacity.global.usage}},{...capacity,retentionDays:0},{...capacity,owner:{used:9,limit:8,remaining:0}},{status:"unavailable",owner:{used:0,limit:8,remaining:8}}])assert.equal(mcpPollStorageCapacitySchema.safeParse(bad).success,false)
})
test("old overview responses report capacity unavailable instead of inventing free slots",()=>{
const original={connections:1,calls:2,errors:0,approvals:0,running:0,enabled:true,observedAt:new Date().toISOString()}
assert.deepEqual(mcpOverviewSchema.parse(original).pollStorageCapacity,{status:"unavailable"})
assert.deepEqual(mcpOverviewSchema.parse({...original,pollStorageCapacity:capacity}).pollStorageCapacity,capacity)
})
+60
View File
@@ -0,0 +1,60 @@
import { z } from "zod"
import { mcpCatalogResponseSchema, mcpConnectionsSchema, mcpSettingsSchema, mcpPolicyPreviewResponseSchema, mcpPollStorageCapacitySchema, type McpConnectionCreate } from "@mmapp/contracts/mcp"
import { requestJson } from "./http-client"
import { downloadHumanArtifact } from "./mcp-artifact"
export const mcpRecordsSchema = z.object({ items: z.array(z.record(z.string(), z.unknown())), total: z.number().int(), nextCursor: z.string().nullable() })
export const mcpOverviewSchema = z.object({ connections: z.number(), calls: z.number(), errors: z.number(), approvals: z.number(), running: z.number(), enabled: z.boolean(), observedAt: z.string(), pollStorageCapacity:mcpPollStorageCapacitySchema.default({status:"unavailable"}) })
const deviceCatalogSchema = z.object({ servers: z.array(z.object({ id: z.number(), name: z.string(), host: z.string(), enabled: z.boolean() })), resources: z.array(z.object({ id: z.string(), title: z.string(), read: z.boolean(), create: z.boolean(), update: z.boolean(), delete: z.boolean(), fields: z.array(z.object({ key: z.string(), type: z.enum(["string", "number", "boolean"]), options: z.array(z.string()).optional(), min: z.number().optional(), max: z.number().optional(), maxLength: z.number().optional(), requiredOnCreate: z.boolean() })) })) })
export function mcpApi(base: string) {
const get = async <T>(path: string, schema: z.ZodType<T>, signal?: AbortSignal): Promise<T> => schema.parse(await requestJson(base, "/api/mcp" + path, { signal }))
const send = async <T>(path: string, body: unknown, schema: z.ZodType<T>, method = "POST"): Promise<T> => schema.parse(await requestJson(base, "/api/mcp" + path, { method, body: JSON.stringify(body) }))
return {
downloadArtifact: (receipt: unknown, signal?: AbortSignal) => downloadHumanArtifact(base, receipt, signal),
coverage: (signal?: AbortSignal) => get("/coverage", z.object({ version: z.literal(1), routes: z.array(z.object({ method: z.string(), path: z.string(), status: z.enum(["mediated", "excluded", "pending"]), operations: z.array(z.string()), reason: z.string() })) }), signal),
uploadRestoreArchive: async (file: File) => z.object({ archiveRef: z.string(), sha256: z.string(), sizeBytes: z.number(), expiresAt: z.string() }).parse(await requestJson(base, "/api/mcp/database/archives", { method: "POST", headers: { "Content-Type": "application/octet-stream" }, body: file })),
restorePlan: (connectionId: string, archiveRef: string, expectedSha256: string) => send("/database/plan", { connectionId, archiveRef, expectedSha256 }, z.object({ planId: z.string(), state: z.string() })),
restoreReceipt: (id: string) => get(`/database/receipts/${encodeURIComponent(id)}`, z.record(z.string(), z.unknown())),
managementChannels: (signal?: AbortSignal) => get("/management-channels", z.object({ channels: z.array(z.record(z.string(), z.unknown())) }), signal),
registerManagement: (input: unknown) => send("/management-channels", input, z.object({ id: z.string(), serverId: z.number(), expiresAt: z.string(), verification: z.string(), independence: z.string() })),
revokeManagement: (id: string) => send(`/management-channels/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
recoverOperation: (id: string) => send(`/operations/${id}/recover`, {}, z.object({ planId: z.string(), state: z.string() })),
checkRoutePath: (id: string, candidateId: string, proofPurpose: "community" | "control-path" = "community") => send(`/routing-profiles/${id}/check`, { candidateId, proofPurpose }, z.record(z.string(), z.unknown())),
reconcileOperation: (id: string) => send(`/operations/${id}/reconcile`, {}, z.record(z.string(), z.unknown())),
deviceCatalog: (connectionId: string, signal?: AbortSignal) => get("/devices/catalog?" + new URLSearchParams({ connectionId }), deviceCatalogSchema, signal),
deviceRows: (connectionId: string, serverId: string, resource: string, offset = 0, signal?: AbortSignal) => get(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}?` + new URLSearchParams({ connectionId, offset: String(offset) }), z.object({ items: z.array(z.record(z.string(), z.unknown())), total: z.number(), nextOffset: z.number().nullable() }), signal),
devicePlan: (connectionId: string, serverId: string, resource: string, action: "create" | "update" | "delete", id: string | undefined, fields: Record<string, unknown>) => send(`/devices/${encodeURIComponent(serverId)}/${encodeURIComponent(resource)}/plan`, { connectionId, action, id, fields }, z.object({ planId: z.string(), state: z.string() })),
revalidate: (id: string) => send("/connections/" + id + "/revalidate", {}, mcpConnectionsSchema.shape.connections.element),
apply: (id: string) => send("/plans/" + id + "/apply", {}, z.object({ id: z.string(), state: z.string() })),
profilePlan: (input: unknown) => send("/routing-profiles/plan", input, z.object({ planId: z.string(), state: z.string() })),
catalog: (signal?: AbortSignal) => get("/catalog", mcpCatalogResponseSchema, signal),
connections: (signal?: AbortSignal) => get("/connections", mcpConnectionsSchema, signal),
overview: (signal?: AbortSignal) => get("/overview", mcpOverviewSchema, signal),
settings: (signal?: AbortSignal) => get("/settings", mcpSettingsSchema, signal),
records: (kind: "requests" | "plans" | "operations" | "audit" | "drift", params: URLSearchParams, signal?: AbortSignal) => get("/" + kind + "?" + params, mcpRecordsSchema, signal),
create: (input: McpConnectionCreate) => send("/connections", input, z.object({ token: z.string(), connection: mcpConnectionsSchema.shape.connections.element })),
update: (id: string, input: McpConnectionCreate) => send("/connections/" + id, input, mcpConnectionsSchema.shape.connections.element, "PUT"),
status: (id: string, status: "active" | "paused" | "revoked") => send(`/connections/${id}/status`, { status }, z.object({ ok: z.literal(true) })),
rotate: (id: string) => send(`/connections/${id}/rotate`, {}, z.object({ token: z.string() })),
check: (id: string) => send(`/connections/${id}/check`, {}, z.object({ ok: z.boolean(), checks: z.record(z.string(), z.boolean()), checkedAt: z.string(), protocolProbe: z.object({ status: z.enum(["passed", "failed", "skipped"]), elapsedMs: z.number(), reason: z.string().optional(), credentialType: z.literal("temporary-no-operations"), clientCredentialsVerified: z.literal(false) }).optional() })),
grants: (id: string, signal?: AbortSignal) => get(`/connections/${id}/grants`, z.object({ grants: z.array(z.object({ id: z.string(), grant: z.record(z.string(), z.unknown()), revoked_at: z.string().nullable() })) }), signal),
grant: (id: string, grant: unknown) => send(`/connections/${id}/grants`, grant, z.object({ token: z.string(), expiresAt: z.string() })),
revokeGrant: (id: string) => send(`/grants/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
profiles: (signal?: AbortSignal) => get("/routing-profiles", z.object({ profiles: z.array(z.record(z.string(), z.unknown())) }), signal),
pauseProfile: (id: string) => send(`/routing-profiles/${id}/pause`, {}, z.object({ ok: z.literal(true) })),
decision: (id: string, approve: boolean) => send(`/plans/${id}/decision`, { approve }, z.object({ ok: z.literal(true) })),
saveSettings: (settings: unknown) => send("/settings", settings, mcpSettingsSchema, "PUT"),
pause: () => send("/pause", {}, z.object({ ok: z.literal(true) })),
stopAutomation: () => send("/stop-automation", {}, z.object({ ok: z.literal(true) })),
preview: (input: unknown) => send("/policy/preview", input, mcpPolicyPreviewResponseSchema),
clients: (signal?: AbortSignal) => get("/oauth/clients", z.object({ clients: z.array(z.object({ id: z.string(), name: z.string(), redirect_uris: z.array(z.string()), enabled: z.boolean() })) }), signal),
createClient: (name: string, redirectUris: string[]) => send("/oauth/clients", { name, redirectUris }, z.object({ clientId: z.string() })),
disableClient: (id: string) => send(`/oauth/clients/${id}`, {}, z.object({ ok: z.literal(true) }), "DELETE"),
consentRequest: (id: string, signal?: AbortSignal) => get("/oauth/requests/" + id, z.object({ id: z.string(), name: z.string(), redirect_uri: z.string(), scope: z.string() }), signal),
consent: (requestId: string, connectionId: string, approve: boolean) => send("/oauth/consent", { requestId, connectionId, approve }, z.object({ redirectUrl: z.string().url() })),
protections: (signal?: AbortSignal) => get("/protections", z.object({ protections: z.array(z.object({ id: z.string(), object_ref: z.object({ kind: z.string(), id: z.string(), serverId: z.number().optional(), protection: z.enum(["deny", "human-approval-required"]) }), reason: z.string() })) }), signal),
protect: (object: unknown, reason: string) => send("/protections", { object, reason }, z.object({ id: z.string() })),
removeProtection: async (id: string) => requestJson(base, "/api/mcp/protections/" + id, { method: "DELETE" }),
}
}
export type McpApi = ReturnType<typeof mcpApi>
+17
View File
@@ -0,0 +1,17 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {emptyNetflowDraft,netflowDraftPatch} from "./netflow-settings-form"
test("NetFlow form sends selected values without defaulting invalid neighbours",()=>{
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,flowListenPort:"4740"},new Set(["flowListenPort"])),{flowListenPort:4740})
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,enabled:false,shareOn:false},new Set(["enabled","shareOn"])),{enabled:false,mapServiceMinSharePct:0})
assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,shareOn:true,mapServiceMinSharePct:"0.25"},new Set(["shareOn","mapServiceMinSharePct"])),{mapServiceMinSharePct:0.25})
for(const raw of ["0","65536","4739x","4.5",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,flowListenPort:raw},new Set(["flowListenPort"])))
for(const raw of ["0","100.01","NaN",""])assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,shareOn:true,mapServiceMinSharePct:raw},new Set(["mapServiceMinSharePct"])))
})
test("NetFlow form uses common network validation without altering valid endpoint",()=>{
for(const value of ["10.255.254.1","192.0.2.1"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,collectorIp:value},new Set(["collectorIp"])),{collectorIp:value})
for(const value of ["10.255.254.0/24","192.0.2.0/28"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,prefix:value},new Set(["prefix"])),{prefix:value})
for(const value of ["","collector","Collector.EXAMPLE.test.","192.0.2.1","2001:db8::1"])assert.deepEqual(netflowDraftPatch({...emptyNetflowDraft,publicEndpoint:value},new Set(["publicEndpoint"])),{publicEndpoint:value})
for(const [field,value] of [["collectorIp","300.1.1.1"],["collectorIp","2001:db8::1"],["prefix","10.0.0.0/33"],["publicEndpoint","https://host.test"],["publicEndpoint","host.test:51821"],["publicEndpoint","$(fixture)"],["publicEndpoint","bad_host.test"],["publicEndpoint","1.2.3.999"]] as const)assert.throws(()=>netflowDraftPatch({...emptyNetflowDraft,[field]:value},new Set([field])))
})
+28
View File
@@ -0,0 +1,28 @@
import { trafficFlowSettingsPatchSchema, type TrafficFlowSettingsDto, type TrafficFlowSettingsPatch } from "@mmapp/contracts/traffic-flow"
export type NetflowDraft = { enabled: boolean; collectorIp: string; flowListenPort: string; wgListenPort: string; prefix: string; publicEndpoint: string; retentionHours: string; topN: string; shareOn: boolean; mapServiceMinSharePct: string }
export const emptyNetflowDraft: NetflowDraft = { enabled: false, collectorIp: "", flowListenPort: "", wgListenPort: "", prefix: "", publicEndpoint: "", retentionHours: "", topN: "", shareOn: false, mapServiceMinSharePct: "" }
export function netflowDraftFromSettings(data: TrafficFlowSettingsDto): NetflowDraft {
return { enabled: data.enabled, collectorIp: data.collectorIp, flowListenPort: String(data.flowListenPort), wgListenPort: String(data.wgListenPort), prefix: data.prefix, publicEndpoint: data.publicEndpoint, retentionHours: String(data.retentionHours), topN: String(data.topN), shareOn: data.mapServiceMinSharePct > 0, mapServiceMinSharePct: String(data.mapServiceMinSharePct) }
}
export function netflowDraftPatch(draft: NetflowDraft, dirty: ReadonlySet<keyof NetflowDraft>): TrafficFlowSettingsPatch {
const patch: TrafficFlowSettingsPatch = {}
for (const field of ["collectorIp", "prefix", "publicEndpoint"] as const) if (dirty.has(field)) {
const value = draft[field].trim()
if ((field !== "publicEndpoint" && !value) || /[\r\n]/.test(value)) throw new Error("Укажите корректное значение: " + field)
patch[field] = value
}
for (const [field, label, max] of [["flowListenPort","UDP IPFIX",65535],["wgListenPort","WG listen",65535],["retentionHours","Хранение (часов)",2147483647],["topN","Top-N разговоров",1000]] as const) if (dirty.has(field)) {
const raw = draft[field], value = Number(raw)
if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 1 || value > max) throw new Error(label + ": укажите целое число от 1 до " + max)
patch[field] = value
}
if (dirty.has("enabled")) patch.enabled = draft.enabled
if (dirty.has("shareOn") || dirty.has("mapServiceMinSharePct")) {
const value = Number(draft.mapServiceMinSharePct)
if (draft.shareOn && (!/^\d+(?:\.\d+)?$/.test(draft.mapServiceMinSharePct) || !Number.isFinite(value) || value <= 0 || value > 100)) throw new Error("Порог карты: укажите долю больше 0 и не более 100%")
patch.mapServiceMinSharePct = draft.shareOn ? value : 0
}
const parsed = trafficFlowSettingsPatchSchema.safeParse(patch)
if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "Укажите допустимые настройки NetFlow")
return parsed.data
}
+50
View File
@@ -0,0 +1,50 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { getSchedulerStatus, listSchedulerRuns, refreshScheduler, runSchedulerJobNow } from "./scheduler"
const base = "https://api.example.test"
const job = { jobKey: "backups", enabled: false, intervalSec: 60, running: false, lastFinishedAt: null, lastStatus: null, lastDurationMs: 0, lastError: null }
const snapshot = { v: 1, job: "backups", sampledAt: "2026-10-01T00:00:00Z", due: true, created: 1, failures: 0, pruned: 0 }
const row = { id: "run-1", jobKey: "backups", startedAt: snapshot.sampledAt, finishedAt: snapshot.sampledAt, durationMs: 0, status: "ok", error: null, resultJson: snapshot }
const ack = { ok: true, result: { status: "ok", error: null, result: snapshot }, status: { jobs: [job] } }
const json = (body: unknown) => new Response(JSON.stringify(body))
test("known empty, false, zero and null stay explicit; missing/duplicate status is rejected", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json({ jobs: [] }); assert.deepEqual(await getSchedulerStatus(base), { jobs: [] })
globalThis.fetch = async () => json({ jobs: [job] }); const status = await getSchedulerStatus(base); assert.equal(status.jobs[0].enabled, false); assert.equal(status.jobs[0].lastDurationMs, 0); assert.equal(status.jobs[0].lastStatus, null)
for (const body of [{}, { jobs: [job, job] }, { jobs: [{ ...job, running: undefined }] }, { jobs: [{ ...job, lastStatus: "unknown" }] }, { jobs: [{ ...job, intervalSec: 0 }] }]) { globalThis.fetch = async () => json(body); await assert.rejects(() => getSchedulerStatus(base)) }
} finally { globalThis.fetch = original }
})
test("run catalogue retains JSONB and normalizes real PostgreSQL timestamps without invented snapshots", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json({ runs: [{ ...row, startedAt: "2026-10-01 00:00:00+00", resultJson: snapshot }] }); const result = await listSchedulerRuns(base, { jobKey: "backups" }); assert.equal(result.runs[0].startedAt, "2026-10-01T00:00:00.000Z"); assert.deepEqual(result.runs[0].resultJson, snapshot)
for (const body of [{}, { runs: [row, row] }, { runs: [{ ...row, durationMs: -1 }] }, { runs: [{ ...row, jobKey: "traffic" }] }]) { globalThis.fetch = async () => json(body); await assert.rejects(() => listSchedulerRuns(base, { jobKey: "backups" })) }
} finally { globalThis.fetch = original }
})
test("successful and partial receipts preserve files, event error and independent status read failure", async () => {
const original = globalThis.fetch
try {
globalThis.fetch = async () => json(ack); assert.equal((await runSchedulerJobNow(base, "backups")).ok, true)
globalThis.fetch = async () => json({ ok: false, result: { status: "error", error: "S3 unavailable", eventError: "event unavailable", result: snapshot }, status: null, statusError: "status unavailable" }); const result = await runSchedulerJobNow(base, "backups"); assert.equal(result.ok, false); assert.equal(result.result.result.job, "backups"); assert.equal(result.result.eventError, "event unavailable"); assert.equal(result.statusError, "status unavailable")
} finally { globalThis.fetch = original }
})
test("empty, mismatched and contradictory POST acknowledgments never confirm a run or retry", async () => {
const original = globalThis.fetch
try {
for (const body of [{}, { ...ack, result: { ...ack.result, result: { ...snapshot, errors: ["failed"] } } }, { ...ack, result: { ...ack.result, result: { ...snapshot, uploadFailures: 1 } } }, { ...ack, ok: false }, { ...ack, result: { ...ack.result, result: null } }, { ...ack, result: { ...ack.result, result: { v: 1, job: "traffic", sampledAt: snapshot.sampledAt, servers: [] } } }, { ...ack, status: null }, { ...ack, result: { ...ack.result, status: "error", error: null } }]) {
let posts = 0; globalThis.fetch = async (_url, init) => { assert.equal(init?.method, "POST"); posts++; return json(body) }; await assert.rejects(() => runSchedulerJobNow(base, "backups")); assert.equal(posts, 1)
}
let posts = 0; globalThis.fetch = async () => { posts++; throw Error("lost acknowledgment") }; await assert.rejects(() => runSchedulerJobNow(base, "backups"), /lost acknowledgment/); assert.equal(posts, 1)
} finally { globalThis.fetch = original }
})
test("invalid input fails before HTTP, caller cancellation is preserved and refresh needs confirmation", async () => {
const original = globalThis.fetch
try {
let calls = 0; globalThis.fetch = async () => { calls++; return json({}) }
await assert.rejects(() => runSchedulerJobNow(base, "invalid" as never)); await assert.rejects(() => listSchedulerRuns(base, { limit: 0 })); assert.equal(calls, 0)
const controller = new AbortController(); controller.abort(); globalThis.fetch = async (_url, init) => { assert.equal(init?.signal?.aborted, true); throw controller.signal.reason }; await assert.rejects(() => getSchedulerStatus(base, { signal: controller.signal }))
globalThis.fetch = async () => json({ ok: false, status: { jobs: [] } }); await assert.rejects(() => refreshScheduler(base))
globalThis.fetch = async () => json({ ok: true, status: { jobs: [] } }); assert.equal((await refreshScheduler(base)).ok, true)
} finally { globalThis.fetch = original }
})
+48
View File
@@ -0,0 +1,48 @@
import { z } from "zod"
import { SCHEDULER_JOB_KEYS, type SchedulerJobKey } from "@/lib/scheduler-settings"
import { parseSchedulerRunSnapshot } from "@/lib/scheduler-run-snapshot"
import { requestJson } from "./http-client"
const key = z.enum(SCHEDULER_JOB_KEYS)
const count = z.number().int().nonnegative().safe()
const timestamp = z.string().min(1).refine(value => Number.isFinite(Date.parse(value)), "Некорректное время планировщика").transform(value => new Date(value).toISOString())
const jobStatus = z.object({ jobKey: key, enabled: z.boolean(), intervalSec: count.positive(), running: z.boolean(), lastFinishedAt: timestamp.nullable(), lastStatus: z.enum(["ok", "error"]).nullable(), lastDurationMs: count.nullable(), lastError: z.string().nullable() })
const statusSchema = z.object({ jobs: z.array(jobStatus).refine(jobs => new Set(jobs.map(job => job.jobKey)).size === jobs.length, "Повторяющиеся задачи планировщика") })
const runSchema = z.object({ id: z.string().min(1), jobKey: key, startedAt: timestamp, finishedAt: timestamp, status: z.enum(["ok", "error"]), error: z.string().nullable(), durationMs: count, resultJson: z.unknown().optional() })
const runsSchema = z.object({ runs: z.array(runSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся записи планировщика") })
const receiptSchema = z.object({ status: z.enum(["ok", "error"]), error: z.string().nullable(), result: z.unknown(), eventError: z.string().optional() }).refine(receipt => receipt.status === "ok" ? receipt.error === null : !!receipt.error, "Статус запуска не согласован с ошибкой")
const responseSchema = z.object({ ok: z.boolean(), result: receiptSchema, status: statusSchema.nullable(), statusError: z.string().optional() }).refine(response => response.ok === (response.result.status === "ok") && (response.status !== null || !!response.statusError), "Подтверждение запуска не согласовано с результатом")
function confirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Ответ планировщика не подтверждён: получены некорректные данные")
return parsed.data
}
function options(init?: RequestInit): RequestInit {
return { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) }
}
async function json(baseUrl: string, path: string, init?: RequestInit) {
return requestJson<unknown>(baseUrl, path, options(init))
}
export async function getSchedulerStatus(baseUrl: string, init?: RequestInit) {
return confirmed(statusSchema, await json(baseUrl, "/api/scheduler/status", { ...init, method: "GET" }))
}
export async function listSchedulerRuns(baseUrl: string, filter: { jobKey?: SchedulerJobKey; limit?: number; offset?: number } = {}, init?: RequestInit) {
const query = z.object({ jobKey: key.optional(), limit: count.min(1).max(200).optional(), offset: count.optional() }).parse(filter)
const params = new URLSearchParams({ limit: String(query.limit ?? 80) })
if (query.jobKey) params.set("jobKey", query.jobKey)
if (query.offset !== undefined) params.set("offset", String(query.offset))
const result = confirmed(runsSchema, await json(baseUrl, "/api/scheduler/runs?" + params.toString(), { ...init, method: "GET" }))
if (query.jobKey && result.runs.some(run => run.jobKey !== query.jobKey)) throw new Error("Журнал относится к другой задаче планировщика")
return result
}
export async function runSchedulerJobNow(baseUrl: string, jobKey: SchedulerJobKey, init?: RequestInit) {
const target = key.parse(jobKey)
const response = confirmed(responseSchema, await json(baseUrl, "/api/scheduler/jobs/" + encodeURIComponent(target) + "/run-now", { ...init, method: "POST" }))
const snapshot = parseSchedulerRunSnapshot(response.result.result)
if (!snapshot || snapshot.job !== target) throw new Error("Результат запуска не подтверждён для выбранной задачи планировщика")
const snapshotHasErrors = ("fatalError" in snapshot && !!snapshot.fatalError) || ("errors" in snapshot && !!snapshot.errors?.length) || (snapshot.job === "backups" && (!!snapshot.failures || !!snapshot.uploadFailures || !!snapshot.pruneFailures || !!snapshot.metadataError || !!snapshot.eventErrors?.length))
if (response.ok && snapshotHasErrors) throw new Error("Успешный статус не согласован с ошибками снимка планировщика")
return { ...response, result: { ...response.result, result: snapshot } }
}
export async function refreshScheduler(baseUrl: string, init?: RequestInit) {
return confirmed(z.object({ ok: z.literal(true), status: statusSchema }), await json(baseUrl, "/api/scheduler/refresh", { ...init, method: "POST" }))
}
+73
View File
@@ -0,0 +1,73 @@
import {
serverListSchema,
serverReadSchema,
testConnectionSchema,
type ServerCreate,
type ServerRead,
type ServerUpdate,
type TestConnectionRequest,
} from "@mmapp/contracts/servers"
import { requestJson as httpRequestJson } from "@/shared/api/http-client"
function requestJson<T>(baseUrl: string, path: string, init?: RequestInit): Promise<T> {
return httpRequestJson<T>(baseUrl, path, {
...init,
// The executor retains its 180s bound; allow 5s for the final HTTP response.
signal: AbortSignal.timeout(path.endsWith("/poll") ? 185_000 : 30_000),
})
}
type TestConnectionResponse = {
success: boolean
latencyMs?: number
identity?: string
version?: string
boardName?: string
uptime?: string
message: string
}
export async function listServers(baseUrl: string): Promise<ServerRead[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/servers")
return serverListSchema.parse(payload)
}
export async function getServer(baseUrl: string, id: string): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, `/api/servers/${id}`)
return serverReadSchema.parse(payload)
}
export async function createServer(baseUrl: string, data: ServerCreate): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, "/api/servers", {
method: "POST",
body: JSON.stringify(data),
})
return serverReadSchema.parse(payload)
}
export async function updateServer(baseUrl: string, id: string, data: ServerUpdate): Promise<ServerRead> {
const payload = await requestJson<unknown>(baseUrl, `/api/servers/${id}`, {
method: "PUT",
body: JSON.stringify(data),
})
return serverReadSchema.parse(payload)
}
export async function deleteServer(baseUrl: string, id: string): Promise<void> {
await requestJson<void>(baseUrl, `/api/servers/${id}`, { method: "DELETE" })
}
export async function pollServer(baseUrl: string, id: string): Promise<void> {
await requestJson<unknown>(baseUrl, `/api/servers/${id}/poll`, { method: "POST" })
}
export async function testServerConnection(
baseUrl: string,
payload: TestConnectionRequest,
): Promise<TestConnectionResponse> {
testConnectionSchema.parse(payload)
return requestJson<TestConnectionResponse>(baseUrl, "/api/servers/test-connection", {
method: "POST",
body: JSON.stringify(payload),
})
}
+10
View File
@@ -0,0 +1,10 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {getSidebarCounts,getSidebarGreCount} from "./sidebar-counts"
test("sidebar unknown counts and partial GRE never become confirmed zeros",async()=>{
const original=globalThis.fetch;try{const signal=new AbortController().signal;globalThis.fetch=async()=>Response.json({servers:0,filterRules:0,uptimeProbes:0,uptimeSpeedProbes:0,monitoringItems:0,recursiveRoutes:0});const counts=await getSidebarCounts("https://backend.test",signal);assert.equal(counts.servers,0);assert.equal(counts.users,undefined)
for(const raw of [{},{servers:-1}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getSidebarCounts("https://backend.test",signal),/не подтверждены/)}
for(const raw of [{},{tunnels:null},{tunnels:[],failures:[{error:"offline"}]}]){globalThis.fetch=async()=>Response.json(raw);await assert.rejects(getSidebarGreCount("https://backend.test",signal),/не подтверждён/)}
globalThis.fetch=async()=>Response.json({tunnels:[],failures:[]});assert.equal(await getSidebarGreCount("https://backend.test",signal),0)
}finally{globalThis.fetch=original}
})
+8
View File
@@ -0,0 +1,8 @@
import {z} from "zod"
import {requestJson} from "./http-client"
const count=z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const schema=z.object({servers:count,filterRules:count,uptimeProbes:count,uptimeSpeedProbes:count,monitoringItems:count,recursiveRoutes:count,certificates:count.optional(),wireguard:count.optional(),ipsec:count.optional(),users:count.optional(),bgpSessions:count.optional(),vxlan:count.optional(),containers:count.optional()})
const greSchema=z.object({tunnels:z.array(z.object({name:z.string(),serverId:z.string()})),failures:z.array(z.unknown()).optional()})
async function read(base:string,path:string,signal:AbortSignal){signal.throwIfAborted();const raw=await requestJson<unknown>(base,path,{signal,cache:"no-store"});signal.throwIfAborted();return raw}
export async function getSidebarCounts(base:string,signal:AbortSignal){const result=schema.safeParse(await read(base,"/api/sidebar-counts",signal));if(!result.success)throw Error("Счётчики меню не подтверждены");return result.data}
export async function getSidebarGreCount(base:string,signal:AbortSignal){const result=greSchema.safeParse(await read(base,"/api/filters/gre-tunnels",signal));if(!result.success||result.data.failures?.length)throw Error("Полный каталог GRE не подтверждён");return result.data.tunnels.length}
+52
View File
@@ -0,0 +1,52 @@
import { statisticsDtoSchema, statisticsPivotDtoSchema } from "@mmapp/contracts/statistics"
import type {
StatisticsDto,
StatisticsPivotDto,
StatisticsPivotQuery,
StatisticsQuery,
} from "@mmapp/contracts/statistics"
import { requestJson } from "@/shared/api/http-client"
export type { StatisticsDto, StatisticsQuery, StatisticsPivotDto, StatisticsPivotQuery }
export { STATISTICS_UNBOUND_USER_ID, STATISTICS_WAN_MARK, STATISTICS_DUP_MARK } from "@mmapp/contracts/statistics"
export async function getStatistics(
baseUrl: string,
query: StatisticsQuery,
): Promise<StatisticsDto> {
const params = new URLSearchParams()
params.set("from", query.from)
params.set("to", query.to)
if (query.serverId != null) params.set("serverId", String(query.serverId))
if (query.userId) params.set("userId", query.userId)
if (query.iface) params.set("iface", query.iface)
if (query.country) params.set("country", query.country)
if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
const parsed = statisticsDtoSchema.safeParse(await requestJson<unknown>(baseUrl, `/api/statistics?${params.toString()}`))
if (!parsed.success) throw new Error("Некорректный ответ статистики")
return parsed.data
}
export async function getStatisticsPivot(
baseUrl: string,
query: StatisticsPivotQuery,
): Promise<StatisticsPivotDto> {
const params = new URLSearchParams()
params.set("from", query.from)
params.set("to", query.to)
params.set("row", query.row)
params.set("col", query.col)
params.set("metric", query.metric)
if (query.serverId != null) params.set("serverId", String(query.serverId))
if (query.userId) params.set("userId", query.userId)
if (query.iface) params.set("iface", query.iface)
if (query.country) params.set("country", query.country)
if (query.service) params.set("service", query.service)
if (query.asn != null) params.set("asn", String(query.asn))
if (query.planes && query.planes !== "unique") params.set("planes", query.planes)
const parsed = statisticsPivotDtoSchema.safeParse(await requestJson<unknown>(baseUrl, `/api/statistics/pivot?${params.toString()}`))
if (!parsed.success) throw new Error("Некорректный ответ сводной статистики")
return parsed.data
}
+49
View File
@@ -0,0 +1,49 @@
import assert from "node:assert/strict"
import {test} from "node:test"
import {downloadSystemDatabaseBackup,restoreSystemDatabaseBackup} from "./system-database"
const dump=new File(["PGDMPfixture"],"fixture.dump")
test("export requires PGDMP bytes and a safe filename; fallback is dump, not db",async()=>{
const original=globalThis.fetch;try{
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"Content-Disposition":"attachment; filename*=UTF-8''backup%20fixture.dump"}})
const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(result.filename,"backup fixture.dump");assert.equal(await result.blob.text(),"PGDMPfixture")
globalThis.fetch=async()=>new Response("PGDMPfixture");assert.equal((await downloadSystemDatabaseBackup("https://backend.test")).filename,"mikrotik-manager.dump")
for(const bytes of ["", "{}", "<html>proxy</html>"]){globalThis.fetch=async()=>new Response(bytes);await assert.rejects(downloadSystemDatabaseBackup("https://backend.test"),/PGDMP/)}
for(const filename of ["../backup.dump","a/b.dump","a\\b.dump"]){globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"Content-Disposition":`attachment; filename="${filename}"`}});await assert.rejects(downloadSystemDatabaseBackup("https://backend.test"),/Имя файла/)}
}finally{globalThis.fetch=original}
})
test("restore validates empty/invalid/oversized file and abort before any POST",async()=>{
const original=globalThis.fetch;let calls=0;try{globalThis.fetch=async()=>{calls++;return Response.json({ok:true})};for(const bytes of ["", "sqlite", "<html>"]){await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",new File([bytes],"wrong.dump")),/PGDMP/)}
const huge=new File(["PGDMP"],"huge.dump");Object.defineProperty(huge,"size",{value:512*1024*1024+1});await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",huge),/512 МБ/)
const controller=new AbortController();controller.abort();await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump,{signal:controller.signal}));assert.equal(calls,0)
}finally{globalThis.fetch=original}
})
test("restore needs literal receipt; malformed/false/204/HTTP error never retry",async()=>{
const original=globalThis.fetch;try{
let calls=0;globalThis.fetch=async(_url,init)=>{calls++;assert.equal(init?.body,dump);assert.equal(new Headers(init?.headers).get("Content-Type"),"application/octet-stream");assert.ok(init?.signal);return Response.json({ok:true,eventError:"audit unavailable"})}
assert.deepEqual(await restoreSystemDatabaseBackup("https://backend.test",dump),{ok:true,eventError:"audit unavailable"});assert.equal(calls,1)
for(const raw of [{},{ok:false},{ok:"true"},{ok:true,eventError:5},{ok:true,error:"restore failed"}]){calls=0;globalThis.fetch=async()=>{calls++;return Response.json(raw)};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/не подтверждено/);assert.equal(calls,1)}
calls=0;globalThis.fetch=async()=>{calls++;return new Response(null,{status:204})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/не подтверждено/);assert.equal(calls,1)
calls=0;globalThis.fetch=async()=>{calls++;return Response.json({error:"restore unknown"},{status:503})};await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",dump),/restore unknown/);assert.equal(calls,1)
}finally{globalThis.fetch=original}
})
test("backup warning header is strict and malformed metadata preserves valid archive with warning",async()=>{
const original=globalThis.fetch;try{
const warnings={listenerError:"listener warning",schedulerError:"scheduler warning",eventError:"event warning"}
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":encodeURIComponent(JSON.stringify(warnings))}})
assert.deepEqual((await downloadSystemDatabaseBackup("https://backend.test")).warnings,warnings)
for(const header of ["%","false",encodeURIComponent(JSON.stringify({listenerError:4})),encodeURIComponent(JSON.stringify({error:"failed"}))]){
globalThis.fetch=async()=>new Response("PGDMPfixture",{headers:{"X-System-Database-Outcome":header}})
const result=await downloadSystemDatabaseBackup("https://backend.test");assert.equal(await result.blob.text(),"PGDMPfixture");assert.match(result.warnings.eventError!,/не подтверждены/)
}
}finally{globalThis.fetch=original}
})
test("restore dispatch checkpoint runs only after local validation and immediately before POST",async()=>{
const original=globalThis.fetch;const calls:string[]=[];try{
globalThis.fetch=async()=>{calls.push("POST");return Response.json({ok:true})}
const options={onDispatch:()=>calls.push("checkpoint")}
await assert.rejects(restoreSystemDatabaseBackup("https://backend.test",new File(["wrong"],"wrong.dump"),options));assert.deepEqual(calls,[])
await restoreSystemDatabaseBackup("https://backend.test",dump,options);assert.deepEqual(calls,["checkpoint","POST"])
}finally{globalThis.fetch=original}
})
+47
View File
@@ -0,0 +1,47 @@
import {z} from "zod"
import {ApiClientError,requestBlob,requestJson} from "@/shared/api/http-client"
const MAX_RESTORE_BYTES=512*1024*1024
const magic=[80,71,68,77,80]
const receiptSchema=z.object({ok:z.literal(true),eventError:z.string().min(1).optional(),listenerError:z.string().min(1).optional(),schedulerError:z.string().min(1).optional(),cleanupError:z.string().min(1).optional(),maintenanceError:z.string().min(1).optional(),migrationError:z.string().min(1).optional()}).strict()
export type SystemDatabaseRestoreReceipt=z.output<typeof receiptSchema>
const warningSchema=receiptSchema.omit({ok:true})
export type SystemDatabaseWarnings=z.output<typeof warningSchema>
type Options={signal?:AbortSignal;onDispatch?:()=>void}
const signal=(options?:Options)=>options?.signal?AbortSignal.any([options.signal,AbortSignal.timeout(600_000)]):AbortSignal.timeout(600_000)
function parseFilename(value:string|null):string{
let name="mikrotik-manager.dump"
if(value){
const utf=/filename\*=UTF-8''([^;]+)/i.exec(value)
const plain=/filename=(?:"([^"]+)"|([^;]+))/i.exec(value)
try{name=utf?.[1]?decodeURIComponent(utf[1]):(plain?.[1]??plain?.[2]??name).trim()}catch{throw new Error("Имя файла бэкапа не подтверждено")}
}
if(!name||/[\\/\r\n\x00]/.test(name)||name==="."||name==="..")throw new Error("Имя файла бэкапа не подтверждено")
return name
}
async function confirmDump(blob:Blob):Promise<void>{
if(blob.size<magic.length)throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
const head=new Uint8Array(await blob.slice(0,magic.length).arrayBuffer())
if(!magic.every((value,index)=>head[index]===value))throw new Error("Файл не похож на резервную копию PostgreSQL (PGDMP)")
}
export async function downloadSystemDatabaseBackup(baseUrl:string,options?:Options):Promise<{blob:Blob;filename:string;warnings:SystemDatabaseWarnings}>{
const activeSignal=signal(options)
const response=await requestBlob(baseUrl,"/api/system/database/backup",{signal:activeSignal})
const blob=await response.blob()
await confirmDump(blob);activeSignal.throwIfAborted()
const rawWarnings=response.headers.get("X-System-Database-Outcome")
let warnings:SystemDatabaseWarnings={}
if(rawWarnings){
try{warnings=warningSchema.parse(JSON.parse(decodeURIComponent(rawWarnings)))}catch{warnings={eventError:"Бэкап подготовлен, но дополнительные результаты не подтверждены"}}
}
return {blob,filename:parseFilename(response.headers.get("Content-Disposition")),warnings}
}
export async function restoreSystemDatabaseBackup(baseUrl:string,file:File,options?:Options):Promise<SystemDatabaseRestoreReceipt>{
if(file.size>MAX_RESTORE_BYTES)throw new ApiClientError("Файл больше 512 МБ — уменьшите бэкап или обратитесь к администратору",413)
const activeSignal=signal(options);activeSignal.throwIfAborted()
await confirmDump(file);activeSignal.throwIfAborted()
options?.onDispatch?.()
const raw=await requestJson<unknown>(baseUrl,"/api/system/database/restore",{method:"POST",headers:{"Content-Type":"application/octet-stream"},body:file,signal:activeSignal})
const receipt=receiptSchema.safeParse(raw)
if(!receipt.success)throw new Error("Восстановление базы не подтверждено сервером")
return receipt.data
}
+42
View File
@@ -0,0 +1,42 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { getTrafficFlowSettings, putTrafficFlowSettings, generateTrafficFlowKeys, getTrafficFlowHostFiles, purgeTrafficFlowData } from "./traffic-flow"
const base = "http://fixture.test"
const settings = { enabled: false, collectorIp: "10.255.254.1", flowListenPort: 4739, wgListenPort: 51821, prefix: "10.255.254.0/24", publicEndpoint: "", hostPublicKey: "", hasHostPrivateKey: false, hubServerId: null, retentionHours: 24, topN: 200, mapServiceMinSharePct: 0, lastDatagramAt: null, lastExporterIp: null, lastError: null, packetsReceived: 0, listenerBound: false, listenerAddress: null, peers: [] }
const keyed = { ...settings, hasHostPrivateKey: true, hostPublicKey: "fixture-public-only" }
const json = (value: unknown) => new Response(JSON.stringify(value))
test("NetFlow GET requires complete DTO and preserves zero/false/known empty peers", async () => {
const original = globalThis.fetch
try { globalThis.fetch = async () => json(settings); assert.deepEqual(await getTrafficFlowSettings(base), settings)
for (const raw of [{}, null, { ...settings, enabled: undefined }, { ...settings, flowListenPort: 65536 }, { ...settings, hasHostPrivateKey: true }, { ...settings, listenerBound: true }, { ...settings, lastDatagramAt: "bad" }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getTrafficFlowSettings(base), /не подтверждены/) }
} finally { globalThis.fetch = original }
})
test("NetFlow PUT validates before HTTP and requires literal ack and exact sent fields", async () => {
const original = globalThis.fetch; let calls = 0
try {
globalThis.fetch = async (_url, init) => { calls++; assert.equal(init?.method, "PUT"); assert.deepEqual(JSON.parse(String(init?.body)), { enabled: false }); return json({ ok: true, settings }) }; await putTrafficFlowSettings(base, { enabled: false }); assert.equal(calls, 1)
for (const raw of [{}, { ok: false, settings }, { ok: true, settings: { ...settings, enabled: true } }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => putTrafficFlowSettings(base, { enabled: false })) }
for (const patch of [{}, { flowListenPort: 65536 }, { wgListenPort: 30.5 }]) { calls = 0; globalThis.fetch = async () => { calls++; return json({}) }; await assert.rejects(() => putTrafficFlowSettings(base, patch)); assert.equal(calls, 0) }
} finally { globalThis.fetch = original }
})
test("NetFlow keys require real created flag and same public key with configured private key", async () => {
const original = globalThis.fetch
try { globalThis.fetch = async () => json({ ok: true, created: false, publicKey: keyed.hostPublicKey, settings: keyed }); assert.equal((await generateTrafficFlowKeys(base)).created, false)
for (const raw of [{}, { ok: true, publicKey: keyed.hostPublicKey, settings: keyed }, { ok: true, created: true, publicKey: "other", settings: keyed }, { ok: true, created: true, publicKey: "fixture", settings }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => generateTrafficFlowKeys(base)) }
} finally { globalThis.fetch = original }
})
test("NetFlow export validates files, identity and leaf filename without synthesizing data", async () => {
const original = globalThis.fetch; const file = { id: "wg", label: "WG", filename: "wg-flow.conf", code: "fixture-only" }
try { globalThis.fetch = async () => json({ files: [file] }); assert.deepEqual((await getTrafficFlowHostFiles(base)).files, [file])
for (const raw of [{}, { files: [] }, { files: [file, file] }, { files: [{ ...file, filename: "../wg.conf" }] }, { files: [{ ...file, code: "" }] }]) { globalThis.fetch = async () => json(raw); await assert.rejects(() => getTrafficFlowHostFiles(base)) }
} finally { globalThis.fetch = original }
})
test("NetFlow purge requires confirmed actual counts and never repeats an unknown POST", async () => {
const original = globalThis.fetch
try {
const receipt = { ok: true, deleted: { buckets: 0, minuteStats: 0, minuteDims: 0, dailyDims: 0 }, fileBytesBefore: 0, fileBytesAfter: 0, vacuumed: false }
globalThis.fetch = async () => json(receipt); assert.deepEqual(await purgeTrafficFlowData(base), receipt)
const partial = {...receipt, fileBytesBefore: null, fileBytesAfter: null, listenerError: "listener unavailable", partitionError: "partition unavailable", eventError: "journal unavailable"}; globalThis.fetch = async () => json(partial); assert.deepEqual(await purgeTrafficFlowData(base), partial)
for (const raw of [{}, { ...receipt, ok: false }, { ...receipt, deleted: { buckets: -1 } }]) { let calls = 0; globalThis.fetch = async () => { calls++; return json(raw) }; await assert.rejects(() => purgeTrafficFlowData(base)); assert.equal(calls, 1) }
} finally { globalThis.fetch = original }
})
+188
View File
@@ -0,0 +1,188 @@
import type {
FlowAnalyticsDto,
FlowClientsDto,
FlowExportersDto,
FlowMapHopsDto,
FlowMonthlyDto,
FlowPurgeDto,
FlowStatsDto,
TrafficFlowHostFile,
TrafficFlowOverlayResult,
TrafficFlowSettingsDto,
TrafficFlowSettingsPatch,
TrafficFlowHostStatus,
} from "@mmapp/contracts/traffic-flow"
import { z } from "zod"
import { trafficFlowSettingsDtoSchema, trafficFlowSettingsPatchSchema, trafficFlowHostFileSchema, flowPurgeDtoSchema, flowAnalyticsDtoSchema, flowMonthlyDtoSchema } from "@mmapp/contracts/traffic-flow"
import { requestJson } from "@/shared/api/http-client"
import { trafficFlowHostStatusSchema } from "@mmapp/contracts/traffic-flow"
export type { TrafficFlowHostFile }
export async function getTrafficFlowHostStatus(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
return flowConfirmed(trafficFlowHostStatusSchema, await requestJson(baseUrl, "/api/traffic/flow/host-status", {
...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(65_000)]),
}))
}
export async function reconcileTrafficFlowHost(baseUrl: string, init?: RequestInit): Promise<TrafficFlowHostStatus> {
const response = flowConfirmed(z.object({ ok: z.literal(true), hostStatus: trafficFlowHostStatusSchema }),
await requestJson(baseUrl, "/api/traffic/flow/host-reconcile", { ...init, method: "POST", signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(120_000)]) }))
return response.hostStatus
}
const flowSettingsSchema = trafficFlowSettingsDtoSchema.extend({ flowListenPort: z.number().int().min(1).max(65535), wgListenPort: z.number().int().min(1).max(65535) }).refine(data => (!data.hasHostPrivateKey || data.hostPublicKey.length > 0) && (!data.listenerBound || !!data.listenerAddress) && (data.lastDatagramAt === null || Number.isFinite(Date.parse(data.lastDatagramAt))) && new Set(data.peers.map(peer => peer.serverId)).size === data.peers.length, "Несогласованные настройки NetFlow")
const flowPatchSchema = trafficFlowSettingsPatchSchema.strict()
// Optional overlay/wire measurements may be unavailable independently of the
// confirmed primary counters. Preserve that distinction instead of inventing zero.
const optionalFlowMetric = z.preprocess(value => typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined, z.number().nonnegative().optional())
const flowAnalyticsSchema = flowAnalyticsDtoSchema.extend({
bytesPayload: optionalFlowMetric, bytesOverlay: optionalFlowMetric,
bytesMesh: optionalFlowMetric, bytesWire: optionalFlowMetric,
bpsOverlay: optionalFlowMetric, bpsWire: optionalFlowMetric,
}).refine(data => [...data.rxSeries, ...data.txSeries].every(value => Number.isFinite(value) && value >= 0), "Некорректные измерения трафика")
function flowConfirmed<T>(schema: z.ZodType<T>, raw: unknown): T {
const parsed = schema.safeParse(raw)
if (!parsed.success) throw new Error("Данные NetFlow не подтверждены: получен некорректный ответ сервера")
return parsed.data
}
function flowJson(baseUrl: string, path: string, init?: RequestInit) {
return requestJson<unknown>(baseUrl, path, { ...init, signal: AbortSignal.any([...(init?.signal ? [init.signal] : []), AbortSignal.timeout(30_000)]) })
}
export async function getTrafficFlowSettings(baseUrl: string, init?: RequestInit): Promise<TrafficFlowSettingsDto> {
return flowConfirmed(flowSettingsSchema, await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "GET" }))
}
export async function putTrafficFlowSettings(baseUrl: string, patch: TrafficFlowSettingsPatch, init?: RequestInit) {
const valid = flowPatchSchema.safeParse(patch)
if (!valid.success || !Object.values(valid.data).some(value => value !== undefined)) throw new Error("Укажите допустимые настройки NetFlow")
const body = valid.data
const response = flowConfirmed(z.object({ ok: z.literal(true), settings: flowSettingsSchema, listenerError: z.string().optional() }), await flowJson(baseUrl, "/api/traffic/flow/settings", { ...init, method: "PUT", body: JSON.stringify(body) }))
if (Object.entries(body).some(([field, value]) => value !== undefined && (response.settings as Record<string, unknown>)[field] !== value)) throw new Error("Сохранение NetFlow не подтверждено: сервер вернул другие настройки")
return response
}
export async function generateTrafficFlowKeys(baseUrl: string, init?: RequestInit) {
const schema = z.object({ ok: z.literal(true), created: z.boolean(), publicKey: z.string().min(1), settings: flowSettingsSchema }).refine(data => data.settings.hasHostPrivateKey && data.publicKey === data.settings.hostPublicKey)
return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/settings/generate-keys", { ...init, method: "POST" }))
}
export async function getTrafficFlowHostFiles(baseUrl: string, init?: RequestInit): Promise<{ files: TrafficFlowHostFile[] }> {
const file = trafficFlowHostFileSchema.extend({ id: z.string().min(1), label: z.string().min(1), filename: z.string().min(1).refine(name => !/[\\/\r\n]/.test(name)), code: z.string().min(1) })
const schema = z.object({ files: z.array(file).min(1).refine(files => new Set(files.map(item => item.id)).size === files.length && new Set(files.map(item => item.filename)).size === files.length) })
return flowConfirmed(schema, await flowJson(baseUrl, "/api/traffic/flow/host-files", { ...init, method: "GET" }))
}
export async function applyTrafficFlowOverlay(
baseUrl: string,
serverId: string | number,
publicEndpoint?: string,
opts?: { disableGreFastPath?: boolean },
): Promise<TrafficFlowOverlayResult> {
return requestJson(baseUrl, "/api/traffic/flow-overlay", {
method: "POST",
body: JSON.stringify({
serverId,
publicEndpoint,
...(opts?.disableGreFastPath ? { disableGreFastPath: true } : {}),
}),
})
}
export async function getTrafficFlows(baseUrl: string, range = "5m"): Promise<FlowStatsDto> {
return requestJson<FlowStatsDto>(baseUrl, `/api/traffic/flows?range=${encodeURIComponent(range)}`)
}
function flowQuery(params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
}): string {
const q = new URLSearchParams()
if (params.range) q.set("range", params.range)
if (params.serverId) q.set("serverId", params.serverId)
if (params.userId) q.set("userId", params.userId)
if (params.iface && params.iface !== "__all__") q.set("iface", params.iface)
if (params.dedup === false) q.set("dedup", "0")
else if (params.dedup === true) q.set("dedup", "1")
if (params.excludeMesh === false) q.set("excludeMesh", "0")
else if (params.excludeMesh === true) q.set("excludeMesh", "1")
if (params.excludeOverlay === false) q.set("excludeOverlay", "0")
else if (params.excludeOverlay === true) q.set("excludeOverlay", "1")
const s = q.toString()
return s ? `?${s}` : ""
}
export async function getFlowExporters(baseUrl: string, range = "5m"): Promise<FlowExportersDto> {
return requestJson<FlowExportersDto>(baseUrl, `/api/traffic/flow/exporters?range=${encodeURIComponent(range)}`)
}
export async function getFlowClients(baseUrl: string, range = "5m"): Promise<FlowClientsDto> {
return requestJson<FlowClientsDto>(baseUrl, `/api/traffic/flow/clients?range=${encodeURIComponent(range)}`)
}
export async function getFlowMapHops(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
} = {},
): Promise<FlowMapHopsDto> {
return requestJson<FlowMapHopsDto>(baseUrl, `/api/traffic/flow/map-hops${flowQuery({
range: params.range ?? "5m",
serverId: params.serverId,
userId: params.userId,
iface: params.iface,
dedup: params.dedup,
excludeMesh: params.excludeMesh,
excludeOverlay: params.excludeOverlay,
})}`)
}
export async function getFlowAnalytics(
baseUrl: string,
params: {
range?: string
serverId?: string
userId?: string
iface?: string
dedup?: boolean
excludeMesh?: boolean
excludeOverlay?: boolean
},
): Promise<FlowAnalyticsDto> {
return flowConfirmed(flowAnalyticsSchema, await requestJson<unknown>(baseUrl, `/api/traffic/flow/analytics${flowQuery(params)}`))
}
export async function getFlowMonthly(
baseUrl: string,
params: { month: string; serverId?: string },
): Promise<FlowMonthlyDto> {
const q = new URLSearchParams()
q.set("month", params.month)
if (params.serverId) q.set("serverId", params.serverId)
const data = flowConfirmed(flowMonthlyDtoSchema, await requestJson<unknown>(baseUrl, `/api/traffic/flow/monthly?${q.toString()}`))
if (data.month !== params.month) throw new Error("Сервер вернул данные другого месяца. Повторите загрузку.")
return data
}
export async function purgeTrafficFlowData(baseUrl: string, init?: RequestInit): Promise<FlowPurgeDto> {
return flowConfirmed(flowPurgeDtoSchema, await flowJson(baseUrl, "/api/traffic/flow/purge", { ...init, method: "POST" }))
}
export async function rebuildTrafficFlowFacts(baseUrl: string): Promise<{
ok: true
buckets: number
facts: number
days: string[]
}> {
return requestJson(baseUrl, "/api/traffic/flow/rebuild-facts", { method: "POST" })
}
export { flowQuery }
+159
View File
@@ -0,0 +1,159 @@
import {
interfaceCatalogStateSchema,
type InterfaceCatalogState,
appUserReadSchema,
catalogInterfaceSchema,
userBindingSchema,
type AppUserCreate,
type AppUserRead,
type AppUserUpdate,
type CatalogInterface,
type UserBinding,
type UserBindingCreate,
} from "@mmapp/contracts/users"
import { z } from "zod"
import { requestJson, requestBlob } from "@/shared/api/http-client"
import type { AppUser, CatalogIface, InterfaceBinding } from "@/lib/users"
const checkedUserSchema = appUserReadSchema.refine(user => user.bindings.every(binding => binding.userId === user.id) && new Set(user.bindings.map(binding => binding.id)).size === user.bindings.length, "Некорректный контекст привязок пользователя")
const usersListPayload = z.object({ users: z.array(checkedUserSchema).refine(rows => new Set(rows.map(row => row.id)).size === rows.length, "Повторяющиеся пользователи в ответе API") })
const userPayload = z.object({ user: checkedUserSchema })
const bindingPayload = z.object({ binding: userBindingSchema })
const checkedCatalogInterface = catalogInterfaceSchema.refine(iface => iface.name.trim().length > 0 && (!(iface.type === "wg" || iface.type === "ipsec") || iface.peers !== undefined || !!iface.peersError) && (!iface.peers || iface.peers.every(peer => peer.publicKey.trim().length > 0) && new Set(iface.peers.map(peer => peer.publicKey)).size === iface.peers.length), "Неполный или неоднозначный каталог интерфейсов")
const catalogPayload = z.object({ interfaces: z.array(checkedCatalogInterface).refine(rows => new Set(rows.map(row => row.name)).size === rows.length, "Повторяющиеся интерфейсы") })
export function toFrontendBinding(b: UserBinding): InterfaceBinding {
return {
id: b.id,
userId: b.userId,
serverId: String(b.serverId),
serverName: b.serverName,
serverSite: b.serverSite,
serverCountry: b.serverCountry,
interfaceName: b.interfaceName,
interfaceType: b.interfaceType,
peerPublicKey: b.peerPublicKey || undefined,
peerName: b.peerName || undefined,
comment: b.comment,
}
}
export function toFrontendUser(u: AppUserRead): AppUser {
return {
id: u.id,
name: u.name,
login: u.login,
email: u.email,
role: u.role,
last: u.lastSeen ?? "—",
avatar: u.avatar,
active: u.active,
sections: u.sections,
servers: u.servers,
bindings: u.bindings.map(toFrontendBinding),
}
}
export async function listAppUsers(baseUrl: string, init?: RequestInit): Promise<AppUser[]> {
const payload = await requestJson<unknown>(baseUrl, "/api/users", init)
return usersListPayload.parse(payload).users.map(toFrontendUser)
}
export async function getAppUser(baseUrl: string, id: string, init?: RequestInit): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, "/api/users/" + encodeURIComponent(id), init)
const user = toFrontendUser(userPayload.parse(payload).user)
if (user.id !== id) throw new Error("Не подтверждён контекст пользователя.")
return user
}
function confirmProfile(user: AppUserRead, data: AppUserCreate | AppUserUpdate) {
for (const key of ["name", "login", "email"] as const) {
if (data[key] !== undefined && user[key] !== data[key]!.trim()) throw new Error("Профиль не подтверждён: ответ содержит другие поля.")
}
for (const key of ["role", "active"] as const) {
if (data[key] !== undefined && user[key] !== data[key]) throw new Error("Профиль не подтверждён: ответ содержит другие права или состояние.")
}
const canonical = (items: unknown[]) => items.map(item => JSON.stringify(Object.entries(item as object).sort(([a], [b]) => a.localeCompare(b)))).sort().join("\n")
for (const key of ["sections", "servers"] as const) {
if (data[key] !== undefined && canonical(user[key]) !== canonical(data[key]!)) throw new Error("Профиль не подтверждён: разрешения отличаются от отправленных.")
}
}
export async function createAppUser(baseUrl: string, data: AppUserCreate, init?: RequestInit): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, "/api/users", {
...init,
method: "POST",
body: JSON.stringify(data),
})
const parsed = userPayload.parse(payload).user
confirmProfile(parsed, data)
const user = toFrontendUser(parsed)
if (user.login !== data.login.trim()) throw new Error("Создание пользователя не подтверждено: другой логин.")
return user
}
export async function updateAppUser(baseUrl: string, id: string, data: AppUserUpdate, init?: RequestInit): Promise<AppUser> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${encodeURIComponent(id)}`, {
...init,
method: "PATCH",
body: JSON.stringify(data),
})
const parsed = userPayload.parse(payload).user
confirmProfile(parsed, data)
const user = toFrontendUser(parsed)
if (user.id !== id) throw new Error("Сохранение пользователя не подтверждено: другой пользователь.")
return user
}
export async function deleteAppUser(baseUrl: string, id: string, init?: RequestInit): Promise<void> {
const response = await requestBlob(baseUrl, "/api/users/" + encodeURIComponent(id), { ...init, method: "DELETE" })
if (response.status !== 204) throw new Error("Удаление пользователя не подтверждено: ожидается HTTP 204.")
}
export async function createUserBinding(
baseUrl: string,
userId: string,
data: UserBindingCreate,
init?: RequestInit,
): Promise<InterfaceBinding> {
const payload = await requestJson<unknown>(baseUrl, `/api/users/${encodeURIComponent(userId)}/bindings`, {
...init,
method: "POST",
body: JSON.stringify(data),
})
const binding = toFrontendBinding(bindingPayload.parse(payload).binding)
if (binding.userId !== userId || binding.serverId !== String(data.serverId) || binding.interfaceName !== data.interfaceName.trim() || (binding.peerPublicKey ?? "") !== (data.peerPublicKey ?? "").trim()) throw new Error("Привязка не подтверждена: ответ другого объекта.")
return binding
}
export async function deleteUserBinding(baseUrl: string, userId: string, bindingId: string, init?: RequestInit): Promise<void> {
const response = await requestBlob(baseUrl, "/api/users/" + encodeURIComponent(userId) + "/bindings/" + encodeURIComponent(bindingId), { ...init, method: "DELETE" })
if (response.status !== 204) throw new Error("Удаление привязки не подтверждено: ожидается HTTP 204.")
}
export async function listInterfaceCatalog(
baseUrl: string,
serverId: string,
init?: RequestInit,
): Promise<CatalogIface[]> {
const payload = await requestJson<unknown>(
baseUrl,
`/api/users/interface-catalog?serverId=${encodeURIComponent(serverId)}`,
init,
)
return catalogPayload.parse(payload).interfaces as CatalogInterface[]
}
export function userApiError(error: unknown): string {
if (error instanceof z.ZodError) return "Некорректный ответ API. Данные не подтверждены."
if (error instanceof Error) return error.message.length > 500 ? error.message.slice(0, 500) + "…" : error.message
return "Запрос не подтверждён."
}
export async function readInterfaceCatalogState(baseUrl: string, serverId: string, init?: RequestInit): Promise<InterfaceCatalogState> {
const raw = await requestJson<unknown>(baseUrl, "/api/users/interface-catalog-state?serverId=" + encodeURIComponent(serverId), init)
const state = interfaceCatalogStateSchema.parse(raw)
if (String(state.serverId) !== serverId) throw new Error("Не подтверждён сервер каталога интерфейсов.")
return state
}
+141
View File
@@ -0,0 +1,141 @@
import { parseConfigRevisions } from "@/lib/config-revisions"
import { wgListResponseSchema, wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard"
import type {
WgCreateInterface,
WgCreatePeerRequest,
WgExportRequest,
WgExportResponse,
WgImportRequest,
WgImportResponse,
WgListResponse,
WgPatchInterface,
WgPatchPeer,
} from "@mmapp/contracts/wireguard"
import { requestJson } from "@/shared/api/http-client"
export async function listWireGuard(
baseUrl: string,
opts?: { serverId?: string; includePrivateKey?: boolean },
): Promise<WgListResponse> {
const q = new URLSearchParams()
if (opts?.serverId) q.set("serverId", opts.serverId)
if (opts?.includePrivateKey) q.set("includePrivateKey", "1")
const qs = q.toString()
const parsed = wgListResponseSchema.safeParse(await requestJson<unknown>(baseUrl, `/api/wireguard${qs ? `?${qs}` : ""}`))
if (!parsed.success) throw new Error("Ответ сервера содержит некорректный каталог WireGuard")
const catalog = parsed.data, failures = catalog.failures ?? []
const uniqueInterfaces = new Set(catalog.interfaces.map(row => JSON.stringify([row.serverId, row.id]))).size === catalog.interfaces.length
const uniquePeers = catalog.interfaces.every(row => new Set(row.peers.map(peer => peer.id)).size === row.peers.length)
const consistentFailures = failures.every(f => f.serverId.trim().length > 0) && new Set(failures.map(f => f.serverId)).size === failures.length && !catalog.interfaces.some(row => failures.some(f => f.serverId === row.serverId))
if (!uniqueInterfaces || !uniquePeers || !consistentFailures) throw new Error("Ответ сервера содержит некорректный каталог WireGuard")
return catalog
}
export async function createWireGuardInterface(
baseUrl: string,
payload: WgCreateInterface,
): Promise<unknown> {
const result = await requestJson<unknown>(baseUrl, "/api/wireguard/interfaces", {
method: "POST",
body: JSON.stringify(payload),
})
const iface = wgIfaceDtoSchema.safeParse(result)
const confirmed = iface.success
? iface.data.name === payload.name && String(iface.data.serverId) === String(payload.serverId)
: typeof result === "object" && result !== null && "ok" in result && result.ok === true && "name" in result && result.name === payload.name
if (!confirmed) throw new Error("Сервер не подтвердил создание интерфейса")
return result
}
export async function patchWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchInterface,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardInterface(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/interfaces/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function createWireGuardPeer(
baseUrl: string,
payload: WgCreatePeerRequest,
): Promise<{ ok: boolean }> {
const result = await requestJson<{ ok: boolean }>(baseUrl, "/api/wireguard/peers", {
method: "POST",
body: JSON.stringify(payload),
})
if (result?.ok !== true) throw new Error("Сервер не подтвердил создание пира")
return result
}
export async function patchWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
payload: WgPatchPeer,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "PATCH",
body: JSON.stringify(payload),
})
}
export async function deleteWireGuardPeer(
baseUrl: string,
serverId: string,
rosId: string,
): Promise<{ ok: boolean }> {
return requestJson(baseUrl, `/api/wireguard/peers/${encodeURIComponent(serverId)}/${encodeURIComponent(rosId)}`, {
method: "DELETE",
})
}
export async function importWireGuard(
baseUrl: string,
payload: WgImportRequest,
): Promise<WgImportResponse> {
const response = await requestJson<unknown>(baseUrl, "/api/wireguard/import", {
method: "POST",
body: JSON.stringify(payload),
})
const parsed = wgImportResponseSchema.safeParse(response)
if (!parsed.success) throw new Error("Некорректный ответ импорта WireGuard")
const result = parsed.data
if (result.dryRun !== (payload.dryRun ?? false) || (!result.dryRun && !result.applied)) {
throw new Error("Сервер не подтвердил применение конфигурации")
}
return result
}
export async function exportWireGuard(
baseUrl: string,
payload: WgExportRequest,
): Promise<WgExportResponse> {
const response = await requestJson<unknown>(baseUrl, "/api/wireguard/export", {
method: "POST",
body: JSON.stringify(payload),
})
const parsed = wgExportResponseSchema.safeParse(response)
if (!parsed.success || parsed.data.format !== payload.format) {
throw new Error("Некорректный ответ экспорта WireGuard")
}
return parsed.data
}
export async function listWireGuardRevisions(baseUrl: string, serverId: string) {
const result = await requestJson<unknown>(baseUrl, "/api/wireguard/revisions?serverId=" + encodeURIComponent(serverId))
return parseConfigRevisions(result, serverId, "wireguard")
}