125 lines
15 KiB
TypeScript
125 lines
15 KiB
TypeScript
import assert from "node:assert/strict"
|
|
import { createServer } from "node:http"
|
|
import { randomUUID } from "node:crypto"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import Fastify from "fastify"
|
|
import { serializerCompiler, validatorCompiler } from "@fastify/type-provider-zod"
|
|
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
|
|
import { pool } from "../../db/index.js"
|
|
import type { AuthUser } from "../../lib/permissions.js"
|
|
import { createIpsecRoutes } from "../../routes/ipsec.js"
|
|
import { getEnabledIpsecServerById } from "../../services/ipsec-live.js"
|
|
import { ipsecPeerDeletePaths } from "../../services/ipsec-peer-delete.js"
|
|
import { createMcpConnection, authenticateMcpToken, setConnectionStatus } from "./store.js"
|
|
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
|
|
|
|
/** SQL/loopback acceptance only. The fixture models the EXPECTED named-reference
|
|
* projection; it does not prove RouterOS rename behavior or forwarding. Root
|
|
* must wire both shared REST and the generic native update alias first. */
|
|
export async function acceptIpsecPeerUpdate(actor:AuthUser){
|
|
const serverId=196,base:Record<string,Record<string,string>[]>=Object.fromEntries(ipsecPeerDeletePaths.map(path=>[path,[]]))
|
|
base["/ip/ipsec/peer"]=[{".id":"*p1",name:"before",address:"192.0.2.1","exchange-mode":"ike2",passive:"false",profile:"default",disabled:"true",comment:"MikrotikManager:ipsec",opaque:"NEVER_PEER_OPAQUE"},{".id":"*p2",name:"other",disabled:"true"}]
|
|
base["/ip/ipsec/identity"]=[{".id":"*i1",peer:"before",disabled:"true","auth-method":"pre-shared-key",secret:"NEVER_PSK",comment:"MikrotikManager:ipsec"},{".id":"*i2",peer:"*p1",secret:"NEVER_PSK_ID"}]
|
|
base["/ip/ipsec/policy"]=[{".id":"*s1",peer:"before,other",dynamic:"false",comment:"MikrotikManager:ipsec",opaque:"NEVER_POLICY"}]
|
|
let catalogs=structuredClone(base),mode="normal",patches=0,reads=0,historyCalls=0,connectionId:string|undefined,duringRead:(()=>Promise<unknown>)|undefined
|
|
const bodies:Record<string,string>[]=[],originalQuery=pool.query.bind(pool)
|
|
const router=createServer(async(req,res)=>{
|
|
const path=(req.url??"").replace(/^\/rest/,"");res.setHeader("content-type","application/json")
|
|
if(req.method==="GET"){
|
|
reads++;if(duringRead){const hook=duringRead;duringRead=undefined;await hook()}
|
|
if(mode==="oversized"){res.end(JSON.stringify("x".repeat(1048577)));return}
|
|
if(mode==="malformed"){res.end("{NEVER_PROVIDER");return}
|
|
res.end(JSON.stringify(catalogs[path]??[]));return
|
|
}
|
|
if(req.method==="PATCH"&&path==="/ip/ipsec/peer/*p1"){
|
|
let text="";for await(const chunk of req)text+=chunk
|
|
const body=JSON.parse(text)as Record<string,string>;bodies.push(body);patches++
|
|
if(mode!=="ignored"){
|
|
const previous=catalogs["/ip/ipsec/peer"][0].name;Object.assign(catalogs["/ip/ipsec/peer"][0],body)
|
|
if(body.name&&mode!=="rename-no-refs"){
|
|
for(const identity of catalogs["/ip/ipsec/identity"])if(identity.peer===previous)identity.peer=body.name
|
|
for(const policy of catalogs["/ip/ipsec/policy"])if(policy.peer)policy.peer=policy.peer.split(",").map(value=>value===previous?body.name:value).join(",")
|
|
}
|
|
}
|
|
if(mode==="collateral")catalogs["/ip/ipsec/identity"][0].secret="NEVER_COLLATERAL"
|
|
if(mode==="lost"){req.socket.destroy();return}
|
|
res.end("{}");return
|
|
}
|
|
res.statusCode=405;res.end("{}")
|
|
})
|
|
await new Promise<void>(resolve=>router.listen(0,"127.0.0.1",resolve))
|
|
const rest=Fastify();rest.setValidatorCompiler(validatorCompiler);rest.setSerializerCompiler(serializerCompiler)
|
|
const reset=()=>{mode="normal";catalogs=structuredClone(base)}
|
|
const wait=async(id:string)=>{for(const deadline=Date.now()+140000;Date.now()<deadline;){const row=(await originalQuery("SELECT state,error_code FROM mcp_operations WHERE id=$1",[id])).rows[0];if(!["pending","running"].includes(row.state))return row;await delay(25)}throw Error("IPsec PATCH remains live; never replay")}
|
|
try{
|
|
assert.equal(Number((await originalQuery("SELECT count(*)::text AS n FROM servers WHERE id=$1",[serverId])).rows[0].n),0)
|
|
await originalQuery("INSERT INTO servers(id,name,host,port,use_ssl,password) VALUES($1,'ipsec-peer-update','127.0.0.1',$2,false,'NEVER_ENDPOINT')",[serverId,(router.address()as{port:number}).port])
|
|
await rest.register(createIpsecRoutes({findServer:getEnabledIpsecServerById,recordRevision:async input=>{historyCalls++;const value=await input.capture();assert.ok(value);return{id:"fixture-history"}as never}}),{prefix:"/api"})
|
|
const access=await createMcpConnection(mcpConnectionCreateSchema.parse({name:"IPsec update",mode:"autonomous",expiresAt:new Date(Date.now()+1800000).toISOString(),permissions:["mm:network:write"],scope:{serverIds:[serverId],operationIds:["network.ipsec.peers.update","ros.ipsec_peer.update","operations.reconcile"],allowGlobal:false},limits:{callsPerMinute:120,mutationsPerHour:1000}}),actor)
|
|
connectionId=access.connection.id
|
|
const principal=await authenticateMcpToken(access.token)
|
|
const prepare=(patch:Record<string,unknown>={})=>invokeMcpTool(principal,"mm_ipsec_peer_edit_plan",{serverId,peerId:"*p1",patch})as Promise<{planId:string;state:string}>
|
|
const apply=async(patch:Record<string,unknown>)=>{const plan=await prepare(patch);assert.equal(plan.state,"awaiting-approval");assert.ok(!JSON.stringify(plan).includes("NEVER_"));await approvePlan(plan.planId,actor,true);const key=randomUUID(),operation=await applyPlan(principal,plan.planId,key);return{plan,key,operation,...await wait(operation.id)}}
|
|
const aliasOnly=await createMcpConnection(mcpConnectionCreateSchema.parse({name:"Alias without business scope",mode:"approval",expiresAt:new Date(Date.now()+600000).toISOString(),permissions:["mm:network:write"],scope:{serverIds:[serverId],operationIds:["ros.ipsec_peer.update"],allowGlobal:false}}),actor)
|
|
try{const before=reads;await assert.rejects(invokeMcpTool(await authenticateMcpToken(aliasOnly.token),"mm_ipsec_peer_update_plan",{serverId,id:"*p1",fields:{name:"blocked"}}));assert.equal(reads,before)}finally{await setConnectionStatus(aliasOnly.connection.id,"paused",actor)}
|
|
const grant=randomUUID();await originalQuery("INSERT INTO mcp_grants(id,connection_id,payload) VALUES($1,$2,$3::jsonb)",[grant,access.connection.id,JSON.stringify({id:grant,credentialId:principal.credentialId,startsAt:new Date(Date.now()-1000).toISOString(),expiresAt:new Date(Date.now()+60000).toISOString(),scope:{serverIds:[],operationIds:["network.ipsec.peers.update"],allowGlobal:false}})])
|
|
try{const before=reads;await assert.rejects(prepare({name:"blocked"}));assert.equal(reads,before)}finally{await originalQuery("DELETE FROM mcp_grants WHERE id=$1",[grant])}
|
|
for(const certificate of["","arbitrary"]){
|
|
const before=patches,history=historyCalls
|
|
await assert.rejects(prepare({certificate}))
|
|
const response=await rest.inject({method:"PATCH",url:`/api/ipsec/peers/${serverId}/*p1`,payload:{certificate}});assert.equal(response.statusCode,400);assert.equal(patches,before);assert.equal(historyCalls,history)
|
|
}
|
|
const request={name:"after",address:"",exchangeMode:"",passive:true,profile:"",disabled:false}
|
|
const unapproved=await prepare(request);await assert.rejects(applyPlan(principal,unapproved.planId,randomUUID()));assert.equal(patches,0)
|
|
let result=await apply(request);assert.equal(result.state,"applied");assert.deepEqual(bodies.at(-1),{name:"after",address:"",profile:"","exchange-mode":"",passive:"yes",disabled:"no"})
|
|
assert.equal(catalogs["/ip/ipsec/identity"][0].peer,"after");assert.equal(catalogs["/ip/ipsec/identity"][1].peer,"*p1");assert.equal(catalogs["/ip/ipsec/policy"][0].peer,"after,other")
|
|
const receipt=(await originalQuery("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[result.operation.id])).rows[0].result
|
|
assert.equal(receipt.historyRecorded,true);assert.equal(receipt.nativeRenameAcceptanceVerified,false);assert.ok(!JSON.stringify(receipt).includes("NEVER_"))
|
|
const reconciled=await invokeMcpTool(principal,"mm_operation_reconcile",{operationId:result.operation.id})as{automaticRetry:boolean;results:{matchesDesiredState:boolean}[]}
|
|
assert.equal(reconciled.automaticRetry,false);assert.equal(reconciled.results[0].matchesDesiredState,true)
|
|
const saved=(await originalQuery("SELECT intended_state,result FROM mcp_operation_steps WHERE operation_id=$1",[result.operation.id])).rows[0]
|
|
for(const tamper of["history","intended","native","count","state"]){
|
|
const intended=structuredClone(saved.intended_state),value=structuredClone(saved.result)
|
|
if(tamper==="history")value.historyRecorded=false
|
|
if(tamper==="intended")intended.input.patch.name="tampered"
|
|
if(tamper==="native")value.peerId="*p2"
|
|
if(tamper==="count")value.completedCommands=0
|
|
await originalQuery("UPDATE mcp_operation_steps SET intended_state=$2::jsonb,result=$3::jsonb,state=$4 WHERE operation_id=$1",[result.operation.id,JSON.stringify(intended),JSON.stringify(value),tamper==="state"?"applied":"verified"])
|
|
const denied=await invokeMcpTool(principal,"mm_operation_reconcile",{operationId:result.operation.id})as{results:{matchesDesiredState:boolean}[]}
|
|
assert.equal(denied.results[0].matchesDesiredState,false)
|
|
}
|
|
await originalQuery("UPDATE mcp_operation_steps SET intended_state=$2::jsonb,result=$3::jsonb,state='verified' WHERE operation_id=$1",[result.operation.id,JSON.stringify(saved.intended_state),JSON.stringify(saved.result)])
|
|
const once=patches;await applyPlan(principal,result.plan.planId,result.key);assert.equal(patches,once)
|
|
reset();const response=await rest.inject({method:"PATCH",url:`/api/ipsec/peers/${serverId}/*p1`,payload:request});assert.equal(response.statusCode,200,response.body);assert.deepEqual(bodies.at(-1),bodies.at(-2));assert.equal(historyCalls,1)
|
|
for(const patch of[{}, {name:"before"}]){reset();const before:number=patches;result=await apply(patch);assert.equal(result.state,"applied");assert.equal(patches,before+(Object.keys(patch).length?1:0));const row=(await originalQuery("SELECT result FROM mcp_operation_steps WHERE operation_id=$1",[result.operation.id])).rows[0].result;assert.equal(row.historyRecorded,true)}
|
|
reset();const alias=await invokeMcpTool(principal,"mm_ipsec_peer_update_plan",{serverId,id:"*p1",fields:{name:"alias",address:"",profile:"default","exchange-mode":"ike2",passive:true,disabled:false,comment:"native alias comment"}})as{planId:string}
|
|
await approvePlan(alias.planId,actor,true);assert.equal((await wait((await applyPlan(principal,alias.planId,randomUUID())).id)).state,"applied");assert.equal(catalogs["/ip/ipsec/identity"][0].peer,"alias");assert.equal(catalogs["/ip/ipsec/peer"][0].comment,"native alias comment")
|
|
for(const fault of["lost","ignored","collateral","rename-no-refs"]){reset();mode=fault;const before:number=patches;result=await apply(request);assert.equal(result.state,fault==="lost"?"unknown":"partial");assert.equal(patches,before+1);await applyPlan(principal,result.plan.planId,result.key);assert.equal(patches,before+1);const reconciliation=await invokeMcpTool(principal,"mm_operation_reconcile",{operationId:result.operation.id})as{results:{matchesDesiredState:boolean}[]};assert.equal(reconciliation.results[0].matchesDesiredState,false);assert.equal(patches,before+1)}
|
|
reset();let historyFailed=false
|
|
pool.query=((...args:unknown[])=>{const sql=typeof args[0]==="string"?args[0]:(args[0]as{text?:string})?.text??"";if(/^insert into "config_revisions"/i.test(sql)){historyFailed=true;return Promise.reject(Error("NEVER_HISTORY_PROVIDER"))}return(originalQuery as(...args:unknown[])=>unknown)(...args)})as typeof pool.query
|
|
try{const before:number=patches;result=await apply({...request,name:"history-failure"});assert.equal(result.state,"partial");assert.equal(historyFailed,true);assert.equal(patches,before+1);await applyPlan(principal,result.plan.planId,result.key);assert.equal(patches,before+1)}finally{pool.query=originalQuery as typeof pool.query}
|
|
reset();const late=await prepare(request);await approvePlan(late.planId,actor,true);let fenced=false
|
|
pool.query=((...args:unknown[])=>{
|
|
const sql=typeof args[0]==="string"?args[0]:(args[0]as{text?:string})?.text??"",params=args[1]as unknown[]|undefined
|
|
const pending=(originalQuery as(...args:unknown[])=>Promise<unknown>)(...args)
|
|
if(!fenced&&sql.startsWith("INSERT INTO mcp_audit")&¶ms?.[1]==="configuration.intent"&&String(params?.[3]).includes('"method":"PATCH"')){fenced=true;return pending.then(value=>{catalogs["/ip/ipsec/identity"][0].secret="NEVER_LATE_SOURCE";return value})}
|
|
return pending
|
|
})as typeof pool.query
|
|
try{const before:number=patches;assert.equal((await wait((await applyPlan(principal,late.planId,randomUUID())).id)).state,"unknown");assert.equal(fenced,true);assert.equal(patches,before)}finally{pool.query=originalQuery as typeof pool.query}
|
|
reset();const stale=await prepare(request);await approvePlan(stale.planId,actor,true);catalogs["/ip/ipsec/identity"][0].secret="NEVER_STALE";const beforeStale=patches;assert.equal((await wait((await applyPlan(principal,stale.planId,randomUUID())).id)).state,"rejected");assert.equal(patches,beforeStale)
|
|
reset();const protection=randomUUID();await originalQuery("INSERT INTO mcp_protections(id,object_ref,created_by) VALUES($1,$2::jsonb,$3)",[protection,JSON.stringify({kind:"ipsec_identity",id:"*i1",serverId,protection:"deny"}),actor.id])
|
|
try{await assert.rejects(prepare(request));assert.equal((await rest.inject({method:"PATCH",url:`/api/ipsec/peers/${serverId}/*p1`,payload:request})).statusCode,409);assert.equal(patches,beforeStale)}finally{await originalQuery("DELETE FROM mcp_protections WHERE id=$1",[protection])}
|
|
reset();mode="oversized";await assert.rejects(prepare(request));mode="malformed";await assert.rejects(prepare(request));reset()
|
|
const cancelled=new AbortController();cancelled.abort();await assert.rejects(invokeMcpTool(principal,"mm_ipsec_peer_edit_plan",{serverId,peerId:"*p1",patch:request},cancelled.signal))
|
|
duringRead=()=>originalQuery("UPDATE servers SET password='changed-source' WHERE id=$1",[serverId]);await assert.rejects(prepare(request));await originalQuery("UPDATE servers SET password='NEVER_ENDPOINT' WHERE id=$1",[serverId])
|
|
await setConnectionStatus(access.connection.id,"paused",actor);await assert.rejects(prepare(request))
|
|
assert.ok(!JSON.stringify((await originalQuery("SELECT payload FROM mcp_audit WHERE connection_id=$1",[connectionId])).rows).includes("NEVER_"))
|
|
assert.ok(reads>0)
|
|
console.log("✓ IPsec peer PATCH SQL/loopback: all REST fields/empty clears, certificate400, same-value PATCH/noop history, expected reference projection, ignored/collateral/lost/no-auto-rename fail closed, shared REST/protections/source and no replay; native rename/forwarding gate remains open")
|
|
}finally{
|
|
pool.query=originalQuery as typeof pool.query
|
|
if(connectionId)await setConnectionStatus(connectionId,"paused",actor)
|
|
await rest.close();await originalQuery("DELETE FROM servers WHERE id=$1",[serverId]);router.closeAllConnections();await new Promise<void>(resolve=>router.close(()=>resolve()))
|
|
}
|
|
}
|