mirror of
https://github.com/LibreQoE/LibreQoS.git
synced 2026-10-25 14:20:05 -05:00
Test: add shaped-device wire contract coverage
This commit is contained in:
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.
|
||||
@@ -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"
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user