Files
MikrotikManager/backend/src/modules/mcp/chr-firewall-write.test.ts
T

85 lines
6.7 KiB
TypeScript

import assert from "node:assert/strict"
import { randomUUID } from "node:crypto"
import { writeFile } from "node:fs/promises"
import { setTimeout as delay } from "node:timers/promises"
import { pool, closePool } from "../../db/index.js"
import { applySqlMigrations } from "../../db/migrate.js"
import { mcpConnectionCreateSchema } from "@mmapp/contracts/mcp"
import { createMcpConnection, authenticateMcpToken, updateMcpSettings } from "./store.js"
import { invokeMcpTool, approvePlan, applyPlan } from "./runtime.js"
import { readRosSnapshot, routerClient } from "./resources.js"
import type { AuthUser } from "../../lib/permissions.js"
const hosts = (process.env.MMAPP_TEST_CHR_HOSTS ?? "").split(",").map(value => value.trim()).filter(Boolean)
assert.equal(hosts.length, 2); assert.equal(new Set(hosts).size, 2)
assert.ok(hosts.every(host => ["194.226.121.150", "104.165.244.7"].includes(host)))
const ports = process.env.MMAPP_TEST_CHR_TUNNEL_PORTS?.split(",").map(Number)
if (ports) assert.ok(ports.length === 2 && ports.every(port => Number.isInteger(port) && port >= 1024 && port <= 65535))
assert.ok(process.env.MMAPP_TEST_CHR_USER && process.env.MMAPP_TEST_CHR_PASSWORD)
const marker = "mcp-test-" + randomUUID().slice(0, 8)
const actor: AuthUser = { id: "chr-firewall-write-owner", apps: ["mm"], permissions: ["mm:settings:admin", "mm:network:admin", "mm:servers:admin"], expiresAt: Date.now() + 3600000 }
const checks: Record<string, unknown>[] = []
const report = { startedAt: new Date().toISOString(), marker, complete: false, packetMatchingVerified: false, fixtures: "private-unreferenced-chain-v4-v6", checks, finishedAt: "" }
const save = () => writeFile(new URL("../../../../docs/mcp-chr-firewall-write-acceptance.json", import.meta.url), JSON.stringify(report, null, 2) + "\n")
try {
await applySqlMigrations(pool)
assert.equal((await pool.query("SELECT count(*)::int AS n FROM servers")).rows[0].n, 0)
await updateMcpSettings({ enabled: true }, actor)
for (const [index, host] of hosts.entries()) await pool.query("INSERT INTO servers(id,name,host,port,use_ssl,username,password) VALUES($1,$2,$3,$4,false,$5,$6)", [index + 1, marker, ports ? "127.0.0.1" : host, ports?.[index] ?? 80, process.env.MMAPP_TEST_CHR_USER, process.env.MMAPP_TEST_CHR_PASSWORD])
const operationIds = ["firewall.rules.create", "firewall.rules.update", "firewall.rules.toggle", "firewall.rules.delete", "ros.ip_filter.delete", "ros.ipv6_filter.delete"]
const access = await createMcpConnection(mcpConnectionCreateSchema.parse({ name: marker, mode: "approval", expiresAt: new Date(Date.now() + 1800000).toISOString(), permissions: ["mm:network:write"], limits: { callsPerMinute: 120, mutationsPerHour: 100 }, scope: { serverIds: [1, 2], operationIds, allowGlobal: false } }), actor)
const principal = await authenticateMcpToken(access.token)
const execute = async (tool: string, input: Record<string, unknown>) => {
const plan = await invokeMcpTool(principal, tool, input) as { planId: string }
await approvePlan(plan.planId, actor, true)
const key = randomUUID(), operation = await applyPlan(principal, plan.planId, key)
for (let attempt = 0; attempt < 200; attempt++) {
const row = (await pool.query("SELECT state,error_code FROM mcp_operations WHERE id=$1", [operation.id])).rows[0]
if (!["pending", "running"].includes(row.state)) {
assert.equal(row.state, "applied", tool + ": " + row.state + "/" + row.error_code)
assert.equal((await applyPlan(principal, plan.planId, key)).id, operation.id)
return operation.id
}
await delay(300)
}
throw Error("Operation pending; never replay lab writes automatically")
}
await save()
for (const [index, host] of hosts.entries()) {
const serverId = index + 1, { client } = await routerClient(serverId)
const resources = ["ip_filter", "ip_nat", "ip_mangle", "ip_raw", "ipv6_filter", "ipv6_nat", "ipv6_mangle", "ipv6_raw"]
const baseline = await Promise.all(resources.map(resource => readRosSnapshot(serverId, resource)))
assert.ok(baseline.filter(snapshot => ["ip_filter", "ipv6_filter"].includes(snapshot.resource)).every(snapshot => snapshot.rows.length === 0), "Lab refuses existing filter rules")
checks.push({ host, phase: "preflight", baselineFingerprints: baseline.map(({ resource, fingerprint }) => ({ resource, fingerprint })) }); await save()
try {
for (const family of ["ip", "ip6"] as const) {
const path = family === "ip" ? "/ip/firewall/filter" : "/ipv6/firewall/filter"
const fields = { chain: marker, action: "accept", disabled: true, comment: marker, srcAddress: family === "ip" ? "192.0.2.0/24" : "2001:db8::/64" }
const invoke = (operation: string, extra: Record<string, unknown>) => execute("mm_firewall_rule_form_" + operation + "_plan", { serverId, rule: { family, table: "filter", operation, ...extra } })
await invoke("create", { fields })
const rows = await client.get<Record<string, string>[]>(path)
assert.equal(rows.length, 1); const rosId = rows[0][".id"]
assert.equal(rows[0].disabled, "true"); assert.equal(rows[0].chain, marker)
await invoke("update", { rosId, fields: { ...fields, action: "drop", comment: marker + " edited" } })
const edited = await client.get<Record<string, string>[]>(path)
assert.equal(edited[0].action, "drop"); assert.equal(edited[0]["src-address"], fields.srcAddress)
await invoke("toggle", { rosId, disabled: false })
assert.equal((await client.get<Record<string, string>[]>(path))[0].disabled, "false")
await invoke("toggle", { rosId, disabled: true })
await invoke("delete", { rosId })
assert.deepEqual(await client.get(path), [])
checks.push({ host, family, phase: "create-edit-toggle-delete", idempotencyVerified: true, chainUnreferenced: true }); await save()
}
} finally {
for (const family of ["ip", "ip6"]) {
const path = family === "ip" ? "/ip/firewall/filter" : "/ipv6/firewall/filter", resource = family === "ip" ? "ip_filter" : "ipv6_filter"
for (const row of await client.get<Record<string, string>[]>(path)) if (row.chain === marker && row.comment.startsWith(marker)) await execute("mm_" + resource + "_delete_plan", { serverId, id: row[".id"] })
}
for (const snapshot of baseline) assert.equal((await readRosSnapshot(serverId, snapshot.resource)).fingerprint, snapshot.fingerprint, "Firewall baseline changed")
checks.push({ host, phase: "cleanup", baselineRestored: true }); await save()
}
console.log("✓ CHR " + serverId + ": IPv4/IPv6 firewall form CRUD, exact matchers, no replay and cleanup")
}
report.complete = true; report.finishedAt = new Date().toISOString(); await save()
} finally { await closePool() }