#[allow(dead_code)]
mod support;
use std::io::ErrorKind;
use std::net::{Ipv4Addr, UdpSocket};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use bacnet_server::server::{BACnetServer, DccPolicy};
use bacnet_transport::bip::BipTransport;
use serde_json::{json, Value};
use support::lab::{lab_database, lab_identity};
use support::run;
const MUTATING: &[(&str, &[&str])] = &[
("write", &["av:1", "pv", "42"]),
("wp", &["av:1", "pv", "42@8"]),
("writem", &["av:1", "pv=42,on=Zone"]),
("wpm", &["av:1", "pv=42@8", "bv:1", "pv=active"]),
("create-object", &["av:100"]),
("delete-object", &["av:1"]),
("file-write", &["1", "no-such-input.bin"]),
(
"ack-alarm",
&[
"ai:1",
"--state",
"1",
"--timestamp",
"sequence:1",
"--ack-time",
"sequence:2",
],
),
("time-sync", &[]),
("ts", &["--utc"]),
("unregister", &[]),
("control", &["enable"]),
("dcc", &["disable-initiation", "--duration", "5"]),
("reinit", &["warmstart"]),
("subscribe", &["ai:1", "--lifetime", "0"]),
("cov", &["ai:1", "--confirmed", "--lifetime", "0"]),
];
fn class(command: &str) -> &'static str {
match command {
"control" | "dcc" | "reinit" => "device-management",
_ => "write",
}
}
fn canonical(command: &str) -> &str {
match command {
"wp" => "write",
"wpm" => "writem",
"ts" => "time-sync",
"dcc" => "control",
"cov" => "subscribe",
other => other,
}
}
fn listener() -> (UdpSocket, String) {
let socket = UdpSocket::bind((Ipv4Addr::LOCALHOST, 0)).unwrap();
socket.set_nonblocking(true).unwrap();
let target = format!("127.0.0.1:{}", socket.local_addr().unwrap().port());
(socket, target)
}
fn assert_nothing_sent(socket: &UdpSocket, what: &str) {
let mut buf = [0u8; 1500];
match socket.recv_from(&mut buf) {
Err(e) if e.kind() == ErrorKind::WouldBlock => {}
other => panic!("{what}: a datagram reached the device: {other:?}"),
}
}
fn args<'a>(
globals: &[&'a str],
command: &'a str,
target: &'a str,
rest: &[&'a str],
) -> Vec<&'a str> {
let mut args = vec!["--interface", "127.0.0.1", "--port", "0"];
args.extend_from_slice(globals);
args.push(command);
args.push(target);
args.extend_from_slice(rest);
args
}
#[tokio::test]
async fn a_mutating_one_shot_without_allow_write_sends_nothing() {
let (socket, target) = listener();
for &(command, rest) in MUTATING {
let output = run(args(&["--format", "table"], command, &target, rest)).await;
assert_eq!(output.status.code(), Some(4), "{command}");
assert!(output.stdout.is_empty(), "{command}");
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("needs --allow-write"),
"{command}: {stderr}"
);
assert!(stderr.contains("nothing was sent"), "{command}: {stderr}");
assert_nothing_sent(&socket, command);
let output = run(args(&["--json"], command, &target, rest)).await;
assert_eq!(output.status.code(), Some(4), "{command}");
assert!(output.stdout.is_empty(), "{command}");
let refusal: Value = serde_json::from_slice(&output.stderr).unwrap();
let missing = match class(command) {
"device-management" => json!(["--allow-write", "--yes"]),
_ => json!(["--allow-write"]),
};
assert_eq!(refusal["error"], "refused", "{command}");
assert_eq!(refusal["reason"], "allow-write-required", "{command}");
assert_eq!(refusal["command"], canonical(command), "{command}");
assert_eq!(refusal["class"], class(command), "{command}");
assert_eq!(refusal["missing"], missing, "{command}");
assert_nothing_sent(&socket, command);
}
}
#[tokio::test]
async fn device_management_off_a_terminal_needs_yes_as_well() {
let (socket, target) = listener();
for &(command, rest) in MUTATING {
if class(command) != "device-management" {
continue;
}
let output = run(args(&["--json", "--allow-write"], command, &target, rest)).await;
assert_eq!(output.status.code(), Some(4), "{command}");
assert!(output.stdout.is_empty(), "{command}");
let refusal: Value = serde_json::from_slice(&output.stderr).unwrap();
assert_eq!(refusal["reason"], "yes-required", "{command}");
assert_eq!(refusal["missing"], json!(["--yes"]), "{command}");
assert_nothing_sent(&socket, command);
}
}
async fn lab(reinitialized: Arc<AtomicBool>) -> (BACnetServer<BipTransport>, String) {
let server = BACnetServer::bip_builder()
.interface(Ipv4Addr::LOCALHOST)
.port(0)
.device(lab_identity(1234))
.database(lab_database(1234))
.dcc_policy(DccPolicy::RequirePassword)
.dcc_password("lab-pass")
.on_reinitialize(move |_, _| {
reinitialized.store(true, Ordering::SeqCst);
Ok(())
})
.build()
.await
.unwrap();
let mac = server.local_mac();
let target = format!("127.0.0.1:{}", u16::from_be_bytes([mac[4], mac[5]]));
(server, target)
}
#[tokio::test]
async fn with_the_flags_the_commands_reach_the_device() {
let reinitialized = Arc::new(AtomicBool::new(false));
let (mut server, target) = lab(reinitialized.clone()).await;
for (globals, command, rest) in [
(&["--allow-write"][..], "write", &["av:1", "pv", "42.5"][..]),
(&[], "write", &["av:1", "pv", "43.5", "--allow-write"]),
(&["--allow-write"], "writem", &["av:1", "pv=44.5"]),
(
&["--allow-write", "--yes"],
"control",
&["enable", "--password", "lab-pass"],
),
(&["--allow-write"], "reinit", &["warmstart", "--yes"]),
] {
let output = run(args(
&[&["--json"], globals].concat(),
command,
&target,
rest,
))
.await;
assert!(
output.status.success(),
"{command}: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let output = run(args(&["--json"], "read", &target, &["av:1", "pv"])).await;
server.stop().await.unwrap();
assert!(output.status.success());
let read: Value = serde_json::from_slice(&output.stdout).unwrap();
assert_eq!(read["value"], "44.5", "{read}");
assert!(reinitialized.load(Ordering::SeqCst));
}
#[cfg(unix)]
async fn active_subscriptions(target: &str) -> String {
let output = run(args(
&["--json"],
"read",
target,
&["device:1234", "active-cov-subscriptions"],
))
.await;
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
let read: Value = serde_json::from_slice(&output.stdout).unwrap();
read["value"].as_str().unwrap().to_string()
}
#[cfg(unix)]
#[tokio::test]
async fn a_default_subscription_is_finite_and_cancelled_when_stopped() {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
let (mut server, target) = lab(Arc::new(AtomicBool::new(false))).await;
let empty = active_subscriptions(&target).await;
for signal in ["-INT", "-TERM"] {
let mut child = support::command()
.args(args(
&["--format", "table"],
"subscribe",
&target,
&["ai:2"],
))
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let stdout = child.stdout.take().unwrap();
let (first, _reader) = support::bounded(tokio::task::spawn_blocking(move || {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
(line, reader)
}))
.await
.unwrap();
assert!(first.contains("lifetime 300s"), "{first}");
let active = active_subscriptions(&target).await;
assert_ne!(active, empty, "no subscription after {first}");
let pid = child.id().to_string();
let killed = std::process::Command::new("kill")
.args([signal, &pid])
.status()
.unwrap();
assert!(killed.success());
let status = support::bounded(tokio::task::spawn_blocking(move || child.wait()))
.await
.unwrap()
.unwrap();
assert!(status.success(), "{signal}: {status:?}");
assert_eq!(
active_subscriptions(&target).await,
empty,
"{signal} left the subscription"
);
}
server.stop().await.unwrap();
}