160 lines
9.1 KiB
TypeScript
160 lines
9.1 KiB
TypeScript
import { test } from "node:test"
|
|
import assert from "node:assert/strict"
|
|
import { createServer } from "node:http"
|
|
import { MikrotikClient, MikrotikError, routerOsAccessHint } from "./mikrotik.js"
|
|
|
|
test("RouterOS access diagnostics distinguish login saturation without assuming a wrong password or retrying", async () => {
|
|
let requests = 0
|
|
const body = '{"detail":"too many concurrent logins (6)","error":500,"message":"Internal Server Error"}'
|
|
const server = createServer((req, res) => { requests++; req.resume(); res.writeHead(500); res.end(body) })
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address(); assert(address && typeof address !== "string")
|
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
try {
|
|
for (const operation of [() => client.get("/interface"), () => client.put("/own", {})]) {
|
|
await assert.rejects(operation, (error: unknown) => {
|
|
assert(error instanceof MikrotikError)
|
|
assert.equal(error.statusCode, 500); assert.equal(error.body, body)
|
|
assert.match(error.message, /превышен лимит одновременных входов/)
|
|
return true
|
|
})
|
|
}
|
|
assert.equal(requests, 2, "Neither reads nor writes may silently retry a refused login")
|
|
assert.match(routerOsAccessHint(401, '{"message":"Unauthorized"}')!, /ограничения входа/)
|
|
assert.doesNotMatch(routerOsAccessHint(401, "")!, /Неверный логин или пароль/)
|
|
for (const unknown of ["not JSON", "null", '{"detail":6}', '{"detail":"disk failure"}']) {
|
|
assert.equal(routerOsAccessHint(500, unknown), undefined)
|
|
}
|
|
assert.equal(routerOsAccessHint(400, body), undefined)
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
})
|
|
|
|
test("RouterOS print preserves native query arrays and property selection", async () => {
|
|
let received: unknown, target = "", method = ""
|
|
const server = createServer(async (req, res) => {
|
|
target = req.url ?? ""; method = req.method ?? ""
|
|
let body = ""; for await (const chunk of req) body += chunk.toString()
|
|
received = JSON.parse(body)
|
|
res.setHeader("content-type", "application/json"); res.end(JSON.stringify([{ ".id": "*1" }]))
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address(); assert(address && typeof address !== "string")
|
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
try {
|
|
const result = await client.post("/ip/route/print", { ".query": ["comment"], ".proplist": [".id", "gateway"] })
|
|
assert.deepEqual(received, { ".query": ["comment"], ".proplist": [".id", "gateway"] })
|
|
assert.equal(target, "/rest/ip/route/print"); assert.equal(method, "POST")
|
|
assert.deepEqual(result, [{ ".id": "*1" }])
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
})
|
|
|
|
test("RouterOS shared REST pool bounds logins across client instances and closes idle sockets", async () => {
|
|
let active=0,peak=0,requests=0
|
|
const connections=new Set<import("node:net").Socket>()
|
|
const server=createServer((req,res)=>{requests++;active++;peak=Math.max(peak,active);req.resume();setTimeout(()=>{res.end('{"ok":true}');active--},20)})
|
|
server.on("connection",socket=>{connections.add(socket);socket.on("close",()=>connections.delete(socket))})
|
|
await new Promise<void>(resolve=>server.listen(0,"127.0.0.1",resolve))
|
|
const address=server.address();assert(address&&typeof address!=="string")
|
|
const clients=Array.from({length:3},()=>new MikrotikClient({host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"}))
|
|
try {
|
|
await Promise.all(Array.from({length:12},(_,i)=>i%2?clients[i%3]!.post("/own",{},2000):clients[i%3]!.get("/own",2000)))
|
|
assert.equal(requests,12);assert.equal(peak,2)
|
|
await new Promise<void>(resolve=>setTimeout(resolve,30));assert.equal(connections.size,0)
|
|
}finally{await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()))}
|
|
})
|
|
|
|
test("RouterOS queued mutation that times out never reaches the device later", async () => {
|
|
let writes=0
|
|
const server=createServer((req)=>{req.resume();if(req.method!=="GET")writes++})
|
|
await new Promise<void>(resolve=>server.listen(0,"127.0.0.1",resolve))
|
|
const address=server.address();assert(address&&typeof address!=="string")
|
|
const client=new MikrotikClient({host:"127.0.0.1",port:address.port,useSsl:false,verifySsl:false,username:"fixture",password:"fixture"})
|
|
try {
|
|
const first=client.get("/hold1",300),second=client.get("/hold2",300),write=client.put("/own",{},40)
|
|
const results=await Promise.allSettled([first,second,write]);assert(results.every(result=>result.status==="rejected"));assert.equal(writes,0)
|
|
}finally{server.closeAllConnections();await new Promise<void>((resolve,reject)=>server.close(error=>error?reject(error):resolve()))}
|
|
})
|
|
|
|
const methods = ["get", "post", "put", "patch", "delete", "file"] as const
|
|
for (const method of methods) {
|
|
test(`RouterOS ${method}: interrupted response rejects promptly without a successful receipt`, async () => {
|
|
let requests = 0
|
|
const server = createServer((req, res) => {
|
|
req.resume()
|
|
req.on("end", () => {
|
|
requests++
|
|
res.writeHead(200, { "content-type": "application/json", "content-length": "1000" })
|
|
res.write('[{"status":')
|
|
setTimeout(() => res.destroy(), 20)
|
|
})
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address()
|
|
assert.ok(address && typeof address !== "string")
|
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
let deadline: ReturnType<typeof setTimeout> | undefined
|
|
try {
|
|
const operation = method === "file" ? client.downloadFile({ id: "*A", name: "", size: 8 }, 500)
|
|
: method === "get" || method === "delete" ? client[method]("/fixture", 500)
|
|
: client[method]("/fixture", {}, 500)
|
|
await assert.rejects(Promise.race([operation, new Promise<never>((_, reject) => {
|
|
deadline = setTimeout(() => reject(Error("Request did not settle after interrupted response")), 1200)
|
|
})]), /ECONNRESET/)
|
|
assert.equal(requests, method === "file" ? 2 : 1, "Writes must not retry; file reads keep two existing compatibility strategies")
|
|
} finally {
|
|
clearTimeout(deadline)
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
})
|
|
}
|
|
|
|
test("RouterOS complete response: success, explicit HTTP rejection and body survive", async () => {
|
|
const server = createServer((req, res) => {
|
|
req.resume()
|
|
req.on("end", () => {
|
|
res.writeHead(req.url?.endsWith("/denied") ? 401 : 200, { "content-type": "application/json" })
|
|
res.end(req.url?.endsWith("/denied") ? '{"message":"Unauthorized"}' : '{"ack":"confirmed"}')
|
|
})
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address()
|
|
assert.ok(address && typeof address !== "string")
|
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
try {
|
|
for (const method of ["get", "post", "put", "patch"] as const) {
|
|
assert.deepEqual(await (method === "get" ? client.get("/fixture", 500) : client[method]("/fixture", {}, 500)), { ack: "confirmed" })
|
|
await assert.rejects(method === "get" ? client.get("/denied", 500) : client[method]("/denied", {}, 500), /401.*Unauthorized/)
|
|
}
|
|
await client.delete("/fixture", 500)
|
|
await assert.rejects(client.delete("/denied", 500), /401.*Unauthorized/)
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
})
|
|
|
|
test("RouterOS nonempty malformed JSON cannot confirm PATCH; explicit empty success stays valid", async () => {
|
|
const server = createServer((req, res) => {
|
|
req.resume()
|
|
req.on("end", () => {
|
|
res.writeHead(200, { "content-type": "application/json" })
|
|
res.end(req.url?.endsWith("/empty") ? "" : "{broken acknowledgement")
|
|
})
|
|
})
|
|
await new Promise<void>(resolve => server.listen(0, "127.0.0.1", resolve))
|
|
const address = server.address()
|
|
assert.ok(address && typeof address !== "string")
|
|
const client = new MikrotikClient({ host: "127.0.0.1", port: address.port, useSsl: false, verifySsl: false, username: "fixture", password: "fixture" })
|
|
try {
|
|
for (const method of ["get", "post", "put", "patch"] as const) {
|
|
await assert.rejects(method === "get" ? client.get("/malformed", 500) : client[method]("/malformed", {}, 500), /JSON|подтверждение/)
|
|
}
|
|
assert.deepEqual(await client.patch("/empty", {}, 500), {})
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
|
|
}
|
|
})
|