Files
MikrotikManager/backend/src/services/wireguard-import-apply.test.ts
T

177 lines
9.4 KiB
TypeScript

import assert from "node:assert/strict"
import { test } from "node:test"
import { MikrotikError } from "./mikrotik.js"
import { applyWireguardImport, WireguardImportApplyError } from "./wireguard-import-apply.js"
import type { WgParsedConfig } from "./wireguard-config.js"
const iface = "/interface/wireguard", addresses = "/ip/address", peers = "/interface/wireguard/peers"
type Row = Record<string, string>
const parsed: WgParsedConfig = { format: "conf", interface: { name: "own-wg", address: "198.18.255.241/30" }, peers: [{ publicKey: "peer-one", allowedAddresses: ["198.18.255.242/32"] }, { publicKey: "peer-two", allowedAddresses: ["198.18.255.243/32"] }] }
const body = (p: WgParsedConfig["peers"][number], name: string) => ({ interface: name, "public-key": p.publicKey, "allowed-address": p.allowedAddresses.join(",") })
function fixture() {
const rows: Record<string, Row[]> = {
[iface]: [{ ".id": "*F", name: "foreign-wg" }],
[addresses]: [{ ".id": "*A", interface: "foreign-wg", address: "192.0.2.1/24" }],
"/ipv6/address": [],
[peers]: [{ ".id": "*P", interface: "foreign-wg", "public-key": "foreign-peer" }],
}
const baseline = structuredClone(rows), deletes: string[] = []
let next = 0, putCount = 0
const client = {
async get<T>(path: string): Promise<T> { return structuredClone(rows[path]) as T },
async put<T>(path: string, value: Record<string, string>): Promise<T> {
putCount++
const row = { ".id": "*OWN/" + (++next), ...value }
rows[path].push(row)
return structuredClone(row) as T
},
async delete(path: string): Promise<void> {
deletes.push(path)
const split = path.lastIndexOf("/"), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1))
rows[base] = rows[base].filter(row => row[".id"] !== id)
},
}
return { client, rows, baseline, deletes, puts: () => putCount }
}
const state = (expected: WireguardImportApplyError["state"]) => (error: unknown) => error instanceof WireguardImportApplyError && error.state === expected
test("IPv6 import confirms normalized receipt, ignores automatic link-local, preserves IPv4 with identical ID", async () => {
const f = fixture(), put = f.client.put
f.rows[addresses].push({ ".id": "*OWN/2", interface: "foreign-wg", address: "192.0.2.8/24" })
f.client.put = async (path, value) => {
const result = await put(path, value)
if (path === "/ipv6/address") f.rows[path].at(-1)!.address = "fd00:152::1/64"
if (path === iface) f.rows["/ipv6/address"].push({ ".id": "*D", interface: "own-wg", address: "fe80::1/64", dynamic: "true" })
return result
}
await applyWireguardImport(f.client, { ...parsed, interface: { ...parsed.interface, address: "fd00:0152:0:0:0:0:0:1/64" } }, body)
assert(f.rows["/ipv6/address"].some(row => row.address === "fd00:152::1/64" && row.advertise === "no"))
assert(f.rows[addresses].some(row => row[".id"] === "*OWN/2" && row.interface === "foreign-wg"))
assert.deepEqual(f.deletes, [])
})
test("IPv6 read failure prevents every import mutation", async () => {
const f = fixture(), get = f.client.get
f.client.get = async path => { if (path === "/ipv6/address") throw new Error("IPv6 denied"); return get(path) }
await assert.rejects(applyWireguardImport(f.client, parsed, body), /IPv6 denied/)
assert.equal(f.puts(), 0)
})
test("rejected peer compensates only owned IPv6 address, not colliding IPv4 ID", async () => {
const f = fixture(), put = f.client.put
f.rows[addresses].push({ ".id": "*OWN/2", interface: "foreign-wg", address: "192.0.2.8/24" })
f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
await assert.rejects(applyWireguardImport(f.client, { ...parsed, interface: { ...parsed.interface, address: "fd00:152::1/64" } }, body), state("rolled-back"))
assert(f.deletes.includes("/ipv6/address/*OWN%2F2"))
assert(!f.deletes.some(path => path.startsWith(addresses)))
assert(f.rows[addresses].some(row => row[".id"] === "*OWN/2"))
})
test("baseline read failure prevents every mutation", async () => {
const f = fixture()
f.client.get = async () => { throw new Error("Read unavailable") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), /Read unavailable/)
assert.equal(f.puts(), 0); assert.deepEqual(f.deletes, [])
})
test("success cannot be reported when receipt identities do not match native state", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
const result = await put(path, value)
if (path === iface) f.rows[iface].find(row => row.name === "own-wg")!.name = "different-native-name"
return result
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
test("success requires native receipt identities and repeat conflicts without mutations", async () => {
const f = fixture()
assert.deepEqual(await applyWireguardImport(f.client, parsed, body), { interfaceName: "own-wg", peersCreated: 2 })
const before = structuredClone(f.rows), puts = f.puts()
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("conflict"))
assert.deepEqual(f.rows, before); assert.equal(f.puts(), puts); assert.deepEqual(f.deletes, [])
})
test("second peer rejection removes only receipt-owned objects, then retry succeeds", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (value["public-key"] === "peer-two") throw new MikrotikError(400, peers, "Rejected secret payload must not be exposed")
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), error => state("rolled-back")(error) && !(error as Error).message.includes("secret payload"))
assert.deepEqual(f.rows, f.baseline)
assert.deepEqual(f.deletes, [peers + "/*OWN%2F3", addresses + "/*OWN%2F2", iface + "/*OWN%2F1"])
f.client.put = put
assert.equal((await applyWireguardImport(f.client, parsed, body)).peersCreated, 2)
})
test("address rejection removes the interface, preserves foreign objects", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { if (path === addresses) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("rolled-back"))
assert.deepEqual(f.rows, f.baseline)
})
for (const path of [iface, addresses, peers]) {
test(`lost response after committed ${path} preserves unknown outcome for review`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (target, value) => { const receipt = await put(target, value); if (target === path) throw new Error("Connection reset after commit"); return receipt }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
}
test("missing create receipt never permits cleanup by interface name", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { await put(path, value); return undefined as never }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
for (const status of [408, 500]) {
test(`HTTP ${status} after commit does not imply a rejected mutation`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { await put(path, value); throw new MikrotikError(status, path, "Uncertain upstream response") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
}
test("receipt referring to baseline identity never permits foreign deletion", async () => {
const f = fixture()
f.client.put = async () => ({ ".id": "*F" }) as never
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.rows, f.baseline); assert.deepEqual(f.deletes, [])
})
test("concurrent foreign child stops compensation before any deletion", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (path === peers) { f.rows[peers].push({ ".id": "*CONCURRENT", interface: "own-wg", "public-key": "foreign" }); throw new MikrotikError(400, path, "rejected") }
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
test("changed native identity stops compensation before deletion", async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => {
if (path === peers) { f.rows[iface].find(row => row.name === "own-wg")!.name = "changed-elsewhere"; throw new MikrotikError(400, path, "rejected") }
return put(path, value)
}
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
assert.deepEqual(f.deletes, [])
})
for (const retain of [false, true]) {
test(`cleanup ${retain ? "unconfirmed" : "rejected"} is never reported as rolled back`, async () => {
const f = fixture(), put = f.client.put
f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
f.client.delete = async () => { if (!retain) throw new Error("Delete rejected") }
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
})
}