Test: add shaped-device wire contract coverage

This commit is contained in:
Herbert Wolverson committed 2026-04-03 16:27:03 -05:00
1 parent 79b7deb866
commit fd23671db1
7 files changed
+304 -91

No files matched your search

@@ -57,11 +57,13 @@ If the page is served through the templated/authenticated router, use `%CACHEBUS
## Validation
- Run `src/rust/lqosd/src/node_manager/js_build/test-node-manager.sh`
- Run `src/rust/lqosd/src/node_manager/js_build/test-build-contract.sh`
- Run `src/rust/lqosd/src/node_manager/js_build/esbuild.sh`
- For broader local iteration, run `src/rust/lqosd/dev_build.sh`
## Notes
- `test-node-manager.sh` uses Node's built-in test runner only; it must not grow a `node_modules` workflow.
- The build-contract test checks page list alignment, required vendored assets, entrypoint/source alignment, and bundle output presence.
- `copy_files.sh` and `dev_build.sh` should stay aligned with the same node_manager asset pipeline.
+1 -2
View File
@@ -9,8 +9,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "[dev_build] Building JS bundles..."
pushd "$SCRIPT_DIR/src/node_manager/js_build" >/dev/null
./esbuild.sh
./test-build-contract.sh
./test-node-manager.sh
popd >/dev/null
echo "[dev_build] Copying static HTML/CSS assets to bin/static2..."
@@ -0,0 +1,88 @@
export function parseIpv4Address(text) {
const parts = text.split(".");
if (parts.length !== 4) return null;
const octets = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = parseInt(part, 10);
if (octet < 0 || octet > 255) return null;
octets.push(octet);
}
return octets;
}
function expandIpv4Tail(text) {
if (!text.includes(".")) return text;
const idx = text.lastIndexOf(":");
if (idx === -1) return null;
const tail = parseIpv4Address(text.slice(idx + 1));
if (!tail) return null;
const high = ((tail[0] << 8) | tail[1]).toString(16);
const low = ((tail[2] << 8) | tail[3]).toString(16);
return text.slice(0, idx) + ":" + high + ":" + low;
}
export function parseIpv6Address(text) {
let input = (text || "").trim().toLowerCase();
if (!input || input.includes("%")) return null;
input = expandIpv4Tail(input);
if (!input) return null;
const doubleColon = input.match(/::/g) || [];
if (doubleColon.length > 1) return null;
let head = [];
let tail = [];
if (input.includes("::")) {
const parts = input.split("::");
head = parts[0] ? parts[0].split(":") : [];
tail = parts[1] ? parts[1].split(":") : [];
} else {
head = input.split(":");
}
const validGroup = (group) => /^[0-9a-f]{1,4}$/.test(group);
if (!head.every(validGroup) || !tail.every(validGroup)) return null;
if (!input.includes("::") && head.length !== 8) return null;
if (input.includes("::") && head.length + tail.length >= 8) return null;
const groups = input.includes("::")
? [
...head,
...new Array(8 - head.length - tail.length).fill("0"),
...tail,
]
: head;
if (groups.length !== 8) return null;
const bytes = [];
for (const group of groups) {
const value = parseInt(group, 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff) return null;
bytes.push((value >> 8) & 0xff, value & 0xff);
}
return bytes;
}
export function parseIpInput(text, family) {
if (!text) return [];
const defaultPrefix = family === 6 ? 128 : 32;
const tokens = text.split(/[\n,]+/);
const result = [];
tokens.forEach((token) => {
const trimmed = token.trim();
if (!trimmed) return;
const parts = trimmed.split("/");
const addr = parts[0].trim();
if (!addr) return;
let prefix = defaultPrefix;
if (parts.length > 1 && parts[1].trim().length > 0) {
const parsed = parseInt(parts[1].trim(), 10);
if (!Number.isNaN(parsed)) prefix = parsed;
}
const encoded = family === 6 ? parseIpv6Address(addr) : parseIpv4Address(addr);
result.push([encoded || addr, prefix]);
});
return result;
}
@@ -0,0 +1,44 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseIpInput,
parseIpv4Address,
parseIpv6Address,
} from "./shaped_device_wire.mjs";
test("encodes IPv4 hosts and prefixes into byte tuples", () => {
assert.deepEqual(parseIpv4Address("192.168.1.2"), [192, 168, 1, 2]);
assert.deepEqual(parseIpInput("192.168.1.2,10.0.0.0/24", 4), [
[[192, 168, 1, 2], 32],
[[10, 0, 0, 0], 24],
]);
});
test("encodes IPv6 hosts into 16-byte tuples", () => {
assert.deepEqual(parseIpv6Address("2001:db8::1"), [
0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01,
]);
assert.deepEqual(parseIpInput("2001:db8::1/64", 6), [[
[0x20, 0x01, 0x0d, 0xb8, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01],
64,
]]);
});
test("encodes IPv4-mapped IPv6 addresses into 16-byte tuples", () => {
assert.deepEqual(parseIpv6Address("::ffff:192.0.2.1"), [
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0xff, 0xff, 0xc0, 0x00, 0x02, 0x01,
]);
});
test("preserves invalid input as strings so UI validation can reject it", () => {
assert.equal(parseIpv4Address("300.1.1.1"), null);
assert.equal(parseIpv6Address("2001:::1"), null);
assert.deepEqual(parseIpInput("300.1.1.1\n2001:::1", 4), [
["300.1.1.1", 32],
["2001:::1", 32],
]);
});
@@ -10,6 +10,7 @@ import {
updateShapedDevice,
validNodeList,
} from "./config/config_helper";
import { parseIpInput } from "./config/shaped_device_wire.mjs";
let current_rows = [];
let network_json = null;
@@ -187,95 +188,6 @@ function ipListToText(list, defaultPrefix) {
.join("\n");
}
function parseIpv4Address(text) {
const parts = text.split(".");
if (parts.length !== 4) return null;
const octets = [];
for (const part of parts) {
if (!/^\d{1,3}$/.test(part)) return null;
const octet = parseInt(part, 10);
if (octet < 0 || octet > 255) return null;
octets.push(octet);
}
return octets;
}
function expandIpv4Tail(text) {
if (!text.includes(".")) return text;
const idx = text.lastIndexOf(":");
if (idx === -1) return null;
const tail = parseIpv4Address(text.slice(idx + 1));
if (!tail) return null;
const high = ((tail[0] << 8) | tail[1]).toString(16);
const low = ((tail[2] << 8) | tail[3]).toString(16);
return text.slice(0, idx) + ":" + high + ":" + low;
}
function parseIpv6Address(text) {
let input = (text || "").trim().toLowerCase();
if (!input || input.includes("%")) return null;
input = expandIpv4Tail(input);
if (!input) return null;
const doubleColon = input.match(/::/g) || [];
if (doubleColon.length > 1) return null;
let head = [];
let tail = [];
if (input.includes("::")) {
const parts = input.split("::");
head = parts[0] ? parts[0].split(":") : [];
tail = parts[1] ? parts[1].split(":") : [];
} else {
head = input.split(":");
}
const validGroup = (group) => /^[0-9a-f]{1,4}$/.test(group);
if (!head.every(validGroup) || !tail.every(validGroup)) return null;
if (!input.includes("::") && head.length !== 8) return null;
if (input.includes("::") && head.length + tail.length >= 8) return null;
const groups = input.includes("::")
? [
...head,
...new Array(8 - head.length - tail.length).fill("0"),
...tail,
]
: head;
if (groups.length !== 8) return null;
const bytes = [];
for (const group of groups) {
const value = parseInt(group, 16);
if (Number.isNaN(value) || value < 0 || value > 0xffff) return null;
bytes.push((value >> 8) & 0xff, value & 0xff);
}
return bytes;
}
function parseIpInput(text, family) {
if (!text) return [];
const defaultPrefix = family === 6 ? 128 : 32;
const tokens = text.split(/[\n,]+/);
const result = [];
tokens.forEach((token) => {
const trimmed = token.trim();
if (!trimmed) return;
const parts = trimmed.split("/");
const addr = parts[0].trim();
if (!addr) return;
let prefix = defaultPrefix;
if (parts.length > 1 && parts[1].trim().length > 0) {
const parsed = parseInt(parts[1].trim(), 10);
if (!Number.isNaN(parsed)) prefix = parsed;
}
const encoded = family === 6 ? parseIpv6Address(addr) : parseIpv4Address(addr);
result.push([encoded || addr, prefix]);
});
return result;
}
function parseSqmOverride(raw) {
const normalized = (raw || "").toLowerCase();
let down = "";
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if ! command -v node >/dev/null 2>&1; then
echo "Missing required dependency: node (used only for built-in node_manager contract tests, no node_modules required)." >&2
exit 1
fi
echo "[test-node-manager] Running frontend contract tests..."
node --test "${SCRIPT_DIR}/src/config/shaped_device_wire.test.mjs"
echo "[test-node-manager] Building bundles..."
"${SCRIPT_DIR}/esbuild.sh"
echo "[test-node-manager] Verifying node_manager build contract..."
"${SCRIPT_DIR}/test-build-contract.sh"
+150
View File
@@ -2003,3 +2003,153 @@ fn payload_hint(payload: &[u8]) -> &'static str {
_ => "binary",
}
}
#[cfg(test)]
mod tests {
use super::decode_ws_request;
use crate::node_manager::ws::messages::WsRequest;
use serde_cbor::Value as CborValue;
use std::collections::BTreeMap;
fn text(value: &str) -> CborValue {
CborValue::Text(value.to_string())
}
fn integer(value: i128) -> CborValue {
CborValue::Integer(value)
}
fn float(value: f64) -> CborValue {
CborValue::Float(value)
}
fn array(items: Vec<CborValue>) -> CborValue {
CborValue::Array(items)
}
fn map(entries: Vec<(&str, CborValue)>) -> CborValue {
let mut out = BTreeMap::new();
for (key, value) in entries {
out.insert(text(key), value);
}
CborValue::Map(out)
}
fn device_value(ipv4: CborValue, ipv6: CborValue) -> CborValue {
map(vec![
("circuit_id", text("circuit-1")),
("circuit_name", text("Circuit 1")),
("device_id", text("device-1")),
("device_name", text("Device 1")),
("parent_node", text("tower-a")),
("mac", text("")),
("ipv4", ipv4),
("ipv6", ipv6),
("download_min_mbps", float(100.0)),
("upload_min_mbps", float(100.0)),
("download_max_mbps", float(200.0)),
("upload_max_mbps", float(200.0)),
("comment", text("")),
])
}
fn create_shaped_device_payload(device: CborValue) -> Vec<u8> {
serde_cbor::to_vec(&map(vec![(
"CreateShapedDevice",
map(vec![("device", device)]),
)]))
.expect("valid create payload")
}
fn update_shaped_device_payload(device: CborValue) -> Vec<u8> {
serde_cbor::to_vec(&map(vec![(
"UpdateShapedDevice",
map(vec![
("original_device_id", text("device-1")),
("device", device),
]),
)]))
.expect("valid update payload")
}
#[test]
fn decodes_create_shaped_device_with_browser_style_ipv4_bytes() {
let payload = create_shaped_device_payload(device_value(
array(vec![array(vec![
array(vec![
integer(192),
integer(168),
integer(1),
integer(2),
]),
integer(32),
])]),
array(vec![]),
));
let request = decode_ws_request(&payload).expect("create request should decode");
match request {
WsRequest::CreateShapedDevice { device } => {
assert_eq!(device.device_id, "device-1");
assert_eq!(device.ipv4.len(), 1);
assert_eq!(device.ipv4[0].0.octets(), [192, 168, 1, 2]);
assert_eq!(device.ipv4[0].1, 32);
}
other => panic!("unexpected request: {other:?}"),
}
}
#[test]
fn decodes_update_shaped_device_with_browser_style_ipv6_bytes() {
let payload = update_shaped_device_payload(device_value(
array(vec![]),
array(vec![array(vec![
array(vec![
integer(0x20),
integer(0x01),
integer(0x0d),
integer(0xb8),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(0),
integer(1),
]),
integer(128),
])]),
));
let request = decode_ws_request(&payload).expect("update request should decode");
match request {
WsRequest::UpdateShapedDevice {
original_device_id,
device,
} => {
assert_eq!(original_device_id, "device-1");
assert_eq!(device.ipv6.len(), 1);
assert_eq!(device.ipv6[0].0.to_string(), "2001:db8::1");
assert_eq!(device.ipv6[0].1, 128);
}
other => panic!("unexpected request: {other:?}"),
}
}
#[test]
fn rejects_legacy_string_encoded_ipv4_payloads() {
let payload = create_shaped_device_payload(device_value(
array(vec![array(vec![text("192.168.1.2"), integer(32)])]),
array(vec![]),
));
let err = decode_ws_request(&payload).expect_err("legacy payload must fail");
assert!(err.contains("expected an array of length 4"));
assert!(err.contains("192.168.1.2"));
}
}