Files
MikrotikManager/backend/src/services/server-poll-rest.test.ts
T

119 lines
9.1 KiB
TypeScript

import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { test } from "node:test"
import { pool } from "../db/index.js"
import { McpError } from "../modules/mcp/security.js"
import type { ServerPollPlan, ServerPollReceipt } from "./server-poll-execution.js"
import { createDurableServerPollRestForTests, executeDurableServerPollRest, type ServerPollRestContext } from "./server-poll-rest.js"
const context = (): ServerPollRestContext => ({ acceptedAt: Date.now(), requestId: randomUUID(), authRequired: true,
actor: { id: "operator", email: "", name: "", apps: ["mm"], permissions: ["mm:servers:write"], issuedAt: Date.now() - 1000, expiresAt: Date.now() + 60000 } })
const gate = () => { let resolve!: () => void; const promise = new Promise<void>(done => { resolve = done }); return { promise, resolve } }
const proof = "a".repeat(64)
function fixture() {
const events: string[] = [], inputs: Parameters<Parameters<typeof createDurableServerPollRestForTests>[0]["session"]>[0][] = []
let outcome: "online" | "offline" = "online", incomplete = false
const dependencies: Parameters<typeof createDurableServerPollRestForTests>[0] = {
authority: () => async () => { events.push("authority"); return { global: true, serverIds: [1, 2], authorityProof: proof } },
session: input => {
inputs.push(input)
const plan: ServerPollPlan = { version: 1, requiresHuman: true, scope: "global", request: input.request, around: new Date().toISOString(),
beforeProof: proof, authorityProof: proof, bindingProof: proof, maintenance: [], affectedServerIds: [1, 2], mutationCost: 8 }
return { prepare: async () => { events.push("prepare"); return plan }, execute: async () => {
events.push("execute")
const receipt: ServerPollReceipt = { receiptId: input.request.receiptId, complete: !incomplete, maintenanceComplete: !incomplete, configurationVerified: true,
nativeReads: 4, confirmedPublications: 2, completedPublications: 2, afterProof: proof, observation: outcome,
snapshot: { id: 11, serverId: input.request.serverId, polledAt: "2026-10-25T12:00:00.000Z", status: outcome,
...(outcome === "online" ? { latencyMs: 3, rosVersion: "7.20", boardName: "owned", uptime: "1h", cpuLoad: 2, freeMemory: 100, totalMemory: 200, identityName: "router" } : {}) } }
return receipt
} }
},
}
return { dependencies, events, inputs, offline: () => { outcome = "offline" }, incomplete: () => { incomplete = true } }
}
test("REST owns UUIDs and exact snapshot response after prepare/execute/post-authority", async () => {
const f = fixture(), execute = createDurableServerPollRestForTests(f.dependencies), accepted = context()
const result = await execute("1", accepted)
assert.equal(result.statusCode, 200)
assert.deepEqual(result.body, { id: 11, serverId: 1, polledAt: "2026-10-25T12:00:00.000Z", status: "online", latencyMs: 3,
rosVersion: "7.20", boardName: "owned", uptime: "1h", cpuLoad: 2, freeMemory: 100, totalMemory: 200, identityName: "router" })
assert(!Object.hasOwn(result.body, "ok")); assert(!Object.hasOwn(result.body, "receiptId"))
assert.deepEqual(f.events, ["authority", "prepare", "execute", "authority"])
const input = f.inputs[0]; assert.match(input.request.receiptId, /^[0-9a-f-]{36}$/); assert.notEqual(input.request.receiptId, input.identity.executionId)
assert.equal(input.identity.kind, "rest"); assert.equal(input.identity.ownerId, "operator"); assert.equal(input.identity.expiresAt, accepted.actor!.expiresAt)
assert.equal(input.identity.issuedAt, accepted.actor!.issuedAt); assert(Object.isFrozen(input.request)); assert(Object.isFrozen(input.identity))
})
test("offline snapshot preserves all nullable legacy SnapshotRead fields", async () => {
const f = fixture(); f.offline()
const reply = await createDurableServerPollRestForTests(f.dependencies)("1", context())
assert.equal(reply.statusCode, 200); assert.equal(reply.body.status, "offline")
for (const field of ["latencyMs", "rosVersion", "boardName", "uptime", "cpuLoad", "freeMemory", "totalMemory", "identityName"]) assert.equal(reply.body[field], null)
})
test("production unauthenticated/expired/app-less actors and malformed ID perform no SQL", async () => {
const prior = pool.connect, previousQuery = pool.query; let io = 0
pool.connect = (() => { io++; throw Error("must not connect") }) as never
pool.query = (() => { io++; throw Error("must not query") }) as never
try {
const missing = await executeDurableServerPollRest("1", { ...context(), actor: undefined }); assert.equal(missing.statusCode, 403)
const expired = context(); expired.actor!.expiresAt = Date.now() - 1
assert.equal((await executeDurableServerPollRest("1", expired)).statusCode, 403)
const wrongApp = context(); wrongApp.actor!.apps = []
assert.equal((await executeDurableServerPollRest("1", wrongApp)).statusCode, 403)
assert.equal((await executeDurableServerPollRest("1;DROP TABLE servers", context())).statusCode, 400)
assert.equal(io, 0)
} finally { pool.connect = prior; pool.query = previousQuery }
})
test("authorized 404 happens before any private session read/publication", async () => {
const f = fixture(); f.dependencies.authority = () => async () => { throw new McpError("NOT_FOUND", "PRIVATE_PROVIDER", 404) }
assert.deepEqual(await createDurableServerPollRestForTests(f.dependencies)("1", context()), { statusCode: 404, body: { error: "Server not found" } })
assert.equal(f.inputs.length, 0)
})
test("REST clones/freeze accepted actor and clips local operation to 180 seconds", async () => {
const f = fixture(), original = context(), pending = gate(), entered = gate()
let captured: ServerPollRestContext["actor"]
f.dependencies.authority = actor => { captured = actor; return async () => { entered.resolve(); await pending.promise; return { global: true, serverIds: [1, 2], authorityProof: proof } } }
const result = createDurableServerPollRestForTests(f.dependencies)("1", original)
await entered.promise; original.actor!.id = "mutated"; original.actor!.permissions.length = 0; pending.resolve()
assert.equal((await result).statusCode, 200); assert.equal(captured!.id, "operator"); assert(Object.isFrozen(captured)); assert(Object.isFrozen(captured!.permissions))
const local = fixture(), accepted = { ...context(), actor: undefined, authRequired: false }, before = Date.now()
assert.equal((await createDurableServerPollRestForTests(local.dependencies)("1", accepted)).statusCode, 200)
assert.equal(local.inputs[0].identity.kind, "rest")
if (local.inputs[0].identity.kind === "rest") assert.equal(local.inputs[0].identity.authentication, "local")
assert(local.inputs[0].identity.expiresAt <= before + 180000 + 10)
})
test("whole host timeout/signal settles crypto gap; late continuation cannot prepare", async () => {
const f = fixture(), pending = gate(), entered = gate()
f.dependencies.authority = () => async () => { entered.resolve(); await pending.promise; return { global: true, serverIds: [1, 2], authorityProof: proof } }
const controller = new AbortController(), response = createDurableServerPollRestForTests(f.dependencies)("1", { ...context(), signal: controller.signal })
await entered.promise; controller.abort(); const result = await response
assert.equal(result.statusCode, 409); pending.resolve(); await new Promise(done => setImmediate(done)); assert.equal(f.inputs.length, 0)
const hanging = fixture(); hanging.dependencies.authority = () => async () => new Promise(() => {})
assert.equal((await createDurableServerPollRestForTests(hanging.dependencies)("1", { ...context(), deadlineAt: Date.now() + 30 })).statusCode, 409)
})
test("incomplete execution and post-publication revocation never return snapshot/private error", async () => {
const f = fixture(); f.incomplete()
const result = await createDurableServerPollRestForTests(f.dependencies)("1", context()); assert.equal(result.statusCode, 502); assert(!Object.hasOwn(result.body, "snapshot"))
const revoked = fixture(); let checks = 0
revoked.dependencies.authority = () => async () => { if (++checks === 2) throw new McpError("PERMISSION_DENIED", "PRIVATE_PROVIDER", 403); return { global: true, serverIds: [1, 2], authorityProof: proof } }
const reply = await createDurableServerPollRestForTests(revoked.dependencies)("1", context()); assert.equal(reply.statusCode, 403); assert(!JSON.stringify(reply).includes("PRIVATE_PROVIDER")); assert(revoked.events.includes("execute"))
})
test("fixed request lifetime is checked again after execution before publication", async () => {
const f = fixture(), original = f.dependencies.session; let active = true
f.dependencies.session = input => {
const session = original(input)
return { ...session, execute: async (...args) => { const receipt = await session.execute(...args); active = false; return receipt } }
}
const reply = await createDurableServerPollRestForTests(f.dependencies)("1", { ...context(), assertRequestActive: () => {
if (!active) throw new McpError("READ_CANCELLED", "PRIVATE_REQUEST_STATE", 409)
} })
assert.equal(reply.statusCode, 409); assert(!JSON.stringify(reply).includes("PRIVATE_REQUEST_STATE")); assert(!Object.hasOwn(reply.body,"serverId"))
})