#![cfg(feature = "redfish-server")]
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use serde_json::Value;
use draupnir::redfish::wire::{self, prop};
use draupnir::redfish_server::{
NodeConfig, RedfishKvmServer, ALL_MESSAGES, BASE_REGISTRY,
};
use draupnir::{Boot, BootOrder, BootSpec, Lifecycle, Machine, PowerState, Result};
const FIXTURES: &[(&str, &str)] = &[
(
"schemas/ServiceRoot.v1_16_1.json",
"d8dbb8748ec06e43c03a972e0f7d398286fe95ea908f9b184e8d5a8da391b519",
),
(
"schemas/ComputerSystem.v1_22_0.json",
"3ba322e18e5d9445c51157fe4b2b3e835bf3992b4a4e60733bf6202342ee615e",
),
(
"schemas/ComputerSystem.json",
"8df03de13e06e4e5b16fd3bfaa806567b364a2f26f63492130023078c83e0c23",
),
(
"schemas/ComputerSystemCollection.json",
"c17c1b287bc2320dfbd3bb3d7ec644ab2726d31f33cbfeac96d6109c3324bc4e",
),
(
"schemas/VirtualMedia.v1_6_3.json",
"d4c4aa15fd15989243c33c188665f6705f5e2bd1e8424b739178591214190e21",
),
(
"schemas/VirtualMediaCollection.json",
"71ec62b65aa41a33ae66ae3ef01e194e61956df2227ce1d4a620ff1f487ba799",
),
(
"schemas/SessionCollection.json",
"96dbe3f737e6b6708d40b84187e6bf6e442186567b84cf2615cf24c2bb75a143",
),
(
"schemas/Resource.json",
"a600172f7b9090c95efad59e3329cbed5d7140d758ab22645785d16ef243bd1c",
),
(
"schemas/redfish-error.v1_0_2.json",
"6ba0f876b30d7c118ee0645c72f3cb3df865a139a5eea757dd66cb4e16aebd24",
),
(
"schemas/Message.v1_1_2.json",
"d543ea0eb8f4aa9fea14f3b3419011f5262466e8e5cc3308966eef57c57445dd",
),
(
"registries/Base.1.19.0.json",
"b44a0bdc30e2834eb7f1cf0aaadd5d8ce1ee27008632b87e210964a018675c9e",
),
(
"mockup/public-rackmount1/index.json",
"3cdbdf2c7bc87d35b4fe7124be221da3e846f1815ebb963178f039114a55383e",
),
(
"mockup/public-rackmount1/Systems/index.json",
"4dd474ce66cd7e11426d781c706dc8c31aff50f8bf57f64dfea8e7eee3f0b765",
),
(
"mockup/public-rackmount1/Systems/437XR1138R2/index.json",
"af03202a1bcd4f16ee8dab92364fc7b1f78cb3088f74d3b79294e9c22ee2957b",
),
(
"mockup/public-rackmount1/Systems/437XR1138R2/VirtualMedia/index.json",
"7402719f415af8b1a340f297051a844949d8d6ad6e3be138e31118a69f29e268",
),
(
"mockup/public-rackmount1/Systems/437XR1138R2/VirtualMedia/CD1/index.json",
"d94e82a2a901965b36d1b710a8d07a2b67d06948c4a548e3165cf4a12a65ff67",
),
];
const MOCK_SYSTEM: &str = "437XR1138R2";
const MOCK_SLOT: &str = "CD1";
fn fixture_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dmtf")
}
fn fixture(rel: &str) -> Value {
use sha2::{Digest, Sha256};
let expected = FIXTURES
.iter()
.find(|(p, _)| *p == rel)
.unwrap_or_else(|| panic!("{rel} is not in the sealed fixture table"))
.1;
let path = fixture_root().join(rel);
let bytes = std::fs::read(&path).unwrap_or_else(|e| {
panic!(
"the DMTF anchor {} is missing — this suite cannot run without it, and a \
skip here would mean asserting draupnir against draupnir: {e}",
path.display()
)
});
let got = format!("{:x}", Sha256::digest(&bytes));
assert_eq!(
got,
expected,
"{rel} does not match the digest in PROVENANCE.md. A DMTF fixture edited to \
make a conformance assertion pass is not an anchor."
);
serde_json::from_slice(&bytes).unwrap_or_else(|e| panic!("{rel} is not JSON: {e}"))
}
#[test]
fn every_dmtf_fixture_is_present_and_unaltered() {
assert!(!FIXTURES.is_empty());
for (rel, _) in FIXTURES {
let v = fixture(rel);
assert!(v.is_object(), "{rel} is not a JSON object");
}
let base = fixture("registries/Base.1.19.0.json");
assert_eq!(base["Id"], BASE_REGISTRY, "the registry we cite is the one we vendored");
assert_eq!(base["OwningEntity"], "DMTF");
assert!(base["@Redfish.Copyright"]
.as_str()
.unwrap_or_default()
.contains("DMTF"));
for rel in FIXTURES.iter().map(|(r, _)| *r).filter(|r| r.starts_with("schemas/")) {
assert_eq!(fixture(rel)["owningEntity"], "DMTF", "{rel}");
}
for rel in FIXTURES.iter().map(|(r, _)| *r).filter(|r| r.starts_with("mockup/")) {
let v = fixture(rel);
assert!(
v["@Redfish.Copyright"]
.as_str()
.unwrap_or_default()
.contains("DMTF"),
"{rel} is not a DMTF mockup"
);
}
println!(
"ANCHOR: {} DMTF fixtures verified against PROVENANCE.md digests",
FIXTURES.len()
);
}
fn schema_properties(def: &Value) -> &serde_json::Map<String, Value> {
if let Some(p) = def.get("properties").and_then(Value::as_object) {
return p;
}
def.get("anyOf")
.and_then(Value::as_array)
.and_then(|branches| {
branches
.iter()
.find_map(|b| b.get("properties").and_then(Value::as_object))
})
.unwrap_or_else(|| panic!("definition has no properties: {def}"))
}
fn schema_required(def: &Value) -> Vec<String> {
let arr = def.get("required").or_else(|| {
def.get("anyOf")
.and_then(Value::as_array)
.and_then(|bs| bs.iter().find_map(|b| b.get("required")))
});
arr.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn is_redfish_annotation(key: &str) -> bool {
let Some((lhs, rhs)) = key.split_once('@') else {
return false;
};
let ident = |s: &str| {
let mut c = s.chars();
matches!(c.next(), Some(f) if f.is_ascii_alphabetic() || f == '_')
&& c.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
};
if !lhs.is_empty() && !ident(lhs) {
return false;
}
let Some((ns, term)) = rhs.split_once('.') else {
return false;
};
matches!(ns, "odata" | "Redfish" | "Message") && ident(term)
}
#[track_caller]
fn check_against_schema(what: &str, resource: &Value, schema: &Value, definition: &str) {
let def = schema
.get("definitions")
.and_then(|d| d.get(definition))
.unwrap_or_else(|| panic!("{definition} is not in the vendored schema for {what}"));
let known: BTreeSet<&str> = schema_properties(def).keys().map(String::as_str).collect();
assert!(!known.is_empty(), "{what}: the schema listed no properties");
let obj = resource
.as_object()
.unwrap_or_else(|| panic!("{what} is not an object"));
assert!(!obj.is_empty(), "{what} is empty — a vacuous pass");
for key in obj.keys() {
assert!(
known.contains(key.as_str()) || is_redfish_annotation(key),
"{what} declares `{key}`, which DMTF's {definition} does not define and \
which is not a well-formed Redfish annotation"
);
}
for req in schema_required(def) {
assert!(
obj.contains_key(&req),
"{what} is missing `{req}`, which DMTF's {definition} marks required"
);
}
}
#[track_caller]
fn check_odata_type(what: &str, resource: &Value, schema_file: &str) -> String {
let ty = resource["@odata.type"]
.as_str()
.unwrap_or_else(|| panic!("{what} has no @odata.type"));
let body = ty
.strip_prefix('#')
.unwrap_or_else(|| panic!("{what}: @odata.type must start with '#', got {ty}"));
let parts: Vec<&str> = body.split('.').collect();
let (namespace, version, type_name) = match parts.as_slice() {
[ns, ver, tn] => (*ns, Some(*ver), *tn),
[ns, tn] => (*ns, None, *tn),
_ => panic!("{what}: malformed @odata.type {ty}"),
};
let schema = fixture(schema_file);
let id = schema["$id"].as_str().unwrap_or_default();
let expect_file = match version {
Some(v) => format!("{namespace}.{v}.json"),
None => format!("{namespace}.json"),
};
assert!(
id.ends_with(&expect_file),
"{what}: @odata.type {ty} names {expect_file}, but the vendored schema is {id}"
);
assert!(
schema["definitions"].get(type_name).is_some(),
"{what}: {expect_file} declares no type `{type_name}`"
);
type_name.to_string()
}
fn schema_enum(schema: &Value, definition: &str) -> BTreeSet<String> {
schema["definitions"][definition]["enum"]
.as_array()
.unwrap_or_else(|| panic!("{definition} declares no enum"))
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
}
#[derive(Default)]
struct Recorder {
specs: Mutex<Vec<BootSpec>>,
powered: Mutex<bool>,
}
impl Recorder {
fn last(&self) -> Option<BootSpec> {
self.specs.lock().unwrap().last().cloned()
}
}
impl Boot for Recorder {
fn boot(&self, spec: &BootSpec) -> Result<Machine> {
self.specs.lock().unwrap().push(spec.clone());
*self.powered.lock().unwrap() = true;
Ok(Machine::started(format!("rec-{}", spec.name), spec))
}
}
impl Lifecycle for Recorder {
fn power_on(&self, _m: &Machine) -> Result<()> {
*self.powered.lock().unwrap() = true;
Ok(())
}
fn power_off(&self, _m: &Machine) -> Result<()> {
*self.powered.lock().unwrap() = false;
Ok(())
}
fn status(&self, _m: &Machine) -> Result<PowerState> {
Ok(if *self.powered.lock().unwrap() {
PowerState::On
} else {
PowerState::Off
})
}
}
fn probe_medium(tag: &str) -> PathBuf {
let p = std::env::temp_dir().join(format!(
"draupnir-rfconf-{tag}-{}.iso",
std::process::id()
));
std::fs::write(&p, b"not really an iso").unwrap();
p
}
const USER: &str = "admin";
const PASS: &str = "conformance-secret";
fn start_server(disk: Option<&str>) -> (RedfishKvmServer, Arc<Recorder>) {
let rec = Arc::new(Recorder::default());
let mut cfg = NodeConfig::new(MOCK_SYSTEM)
.media_slot(MOCK_SLOT)
.credentials(USER, PASS);
if let Some(d) = disk {
cfg = cfg.local_disk(d);
}
let server = RedfishKvmServer::start_with(cfg, rec.clone()).expect("the BMC starts");
(server, rec)
}
fn agent(server: &RedfishKvmServer) -> ureq::Agent {
use ureq::tls::{Certificate, RootCerts, TlsConfig};
let cert = Certificate::from_pem(server.cert_pem().as_bytes())
.expect("the server hands out a well-formed PEM");
let tls = TlsConfig::builder()
.root_certs(RootCerts::from([cert]))
.build();
ureq::config::Config::builder()
.tls_config(tls)
.http_status_as_error(false)
.build()
.into()
}
struct Exchange {
status: u16,
allow: Option<String>,
odata_version: Option<String>,
www_authenticate: Option<String>,
connection: Option<String>,
body: Value,
}
fn exchange(resp: ureq::http::Response<ureq::Body>) -> Exchange {
let status = resp.status().as_u16();
let hdr = |n: &str| {
resp.headers()
.get(n)
.and_then(|v| v.to_str().ok())
.map(str::to_string)
};
let allow = hdr("allow");
let odata_version = hdr("odata-version");
let www_authenticate = hdr("www-authenticate");
let connection = hdr("connection");
let text = resp.into_body().read_to_string().unwrap_or_default();
let body = serde_json::from_str(&text).unwrap_or(Value::Null);
Exchange {
status,
allow,
odata_version,
www_authenticate,
connection,
body,
}
}
fn get(a: &ureq::Agent, server: &RedfishKvmServer, path: &str) -> Exchange {
exchange(
a.get(format!("{}{path}", server.base_url()))
.header("Authorization", &wire::basic_auth_header(USER, PASS))
.call()
.expect("the BMC answers"),
)
}
fn post(a: &ureq::Agent, server: &RedfishKvmServer, path: &str, body: &Value) -> Exchange {
exchange(
a.post(format!("{}{path}", server.base_url()))
.header("Authorization", &wire::basic_auth_header(USER, PASS))
.send_json(body)
.expect("the BMC answers"),
)
}
fn patch(a: &ureq::Agent, server: &RedfishKvmServer, path: &str, body: &Value) -> Exchange {
exchange(
a.patch(format!("{}{path}", server.base_url()))
.header("Authorization", &wire::basic_auth_header(USER, PASS))
.send_json(body)
.expect("the BMC answers"),
)
}
#[test]
fn the_wire_paths_are_the_uris_dmtfs_mockup_publishes() {
let root = fixture("mockup/public-rackmount1/index.json");
let systems = fixture("mockup/public-rackmount1/Systems/index.json");
let system = fixture("mockup/public-rackmount1/Systems/437XR1138R2/index.json");
let vmc = fixture("mockup/public-rackmount1/Systems/437XR1138R2/VirtualMedia/index.json");
let vm = fixture("mockup/public-rackmount1/Systems/437XR1138R2/VirtualMedia/CD1/index.json");
assert_eq!(root["@odata.id"], wire::SERVICE_ROOT_PATH);
assert_eq!(root["Systems"]["@odata.id"], wire::SYSTEMS_PATH);
assert_eq!(
root["Links"]["Sessions"]["@odata.id"], wire::SESSIONS_PATH,
"even the session collection URI is DMTF's"
);
assert_eq!(systems["@odata.id"], wire::SYSTEMS_PATH);
assert_eq!(
systems["Members"][0]["@odata.id"],
wire::system_path(MOCK_SYSTEM)
);
assert_eq!(system["@odata.id"], wire::system_path(MOCK_SYSTEM));
assert_eq!(
system["VirtualMedia"]["@odata.id"],
wire::virtual_media_collection_path(MOCK_SYSTEM)
);
assert_eq!(
vmc["@odata.id"],
wire::virtual_media_collection_path(MOCK_SYSTEM)
);
assert_eq!(
vm["@odata.id"],
wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT)
);
assert_eq!(
system["Actions"][wire::ACTION_RESET][prop::TARGET],
wire::reset_path(MOCK_SYSTEM)
);
let members: Vec<&str> = vmc["Members"]
.as_array()
.unwrap()
.iter()
.filter_map(|m| m["@odata.id"].as_str())
.collect();
assert!(
members.contains(&wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT).as_str()),
"DMTF's VirtualMedia collection lists {members:?}"
);
}
#[cfg(feature = "backend-redfish")]
#[test]
fn the_client_asks_for_exactly_the_paths_the_server_routes() {
let bmc = draupnir::BmcEndpoint {
host: "https://bmc.example".into(),
username: USER.into(),
system_id: MOCK_SYSTEM.into(),
};
for (url, path) in [
(
format!("https://bmc.example{}", wire::system_path(MOCK_SYSTEM)),
wire::system_path(MOCK_SYSTEM),
),
(
format!("https://bmc.example{}", wire::reset_path(MOCK_SYSTEM)),
wire::reset_path(MOCK_SYSTEM),
),
] {
assert_eq!(url.strip_prefix(&bmc.host).unwrap(), path);
}
}
#[test]
fn the_service_root_conforms_and_matches_dmtfs_mockup_shape() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let probe = exchange(
a.get(format!("{}{}", server.base_url(), wire::PROTOCOL_VERSION_PATH))
.call()
.unwrap(),
);
assert_eq!(probe.status, 200);
assert_eq!(probe.body["v1"], wire::SERVICE_ROOT_PATH);
let r = get(&a, &server, wire::SERVICE_ROOT_PATH);
assert_eq!(r.status, 200);
assert_eq!(
r.odata_version.as_deref(),
Some("4.0"),
"every Redfish response carries OData-Version"
);
check_odata_type("ServiceRoot", &r.body, "schemas/ServiceRoot.v1_16_1.json");
check_against_schema(
"ServiceRoot",
&r.body,
&fixture("schemas/ServiceRoot.v1_16_1.json"),
"ServiceRoot",
);
let mock = fixture("mockup/public-rackmount1/index.json");
assert_eq!(r.body["@odata.id"], mock["@odata.id"]);
assert_eq!(r.body["Systems"]["@odata.id"], mock["Systems"]["@odata.id"]);
assert_eq!(
r.body["Links"]["Sessions"]["@odata.id"],
mock["Links"]["Sessions"]["@odata.id"]
);
let sessions = get(&a, &server, wire::SESSIONS_PATH);
assert_eq!(sessions.status, 200, "Links.Sessions must not dangle");
check_odata_type("SessionCollection", &sessions.body, "schemas/SessionCollection.json");
check_against_schema(
"SessionCollection",
&sessions.body,
&fixture("schemas/SessionCollection.json"),
"SessionCollection",
);
assert_eq!(get(&a, &server, wire::SERVICE_ROOT_PATH_BARE).status, 200);
}
#[test]
fn the_system_collection_conforms_and_lists_the_node() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let r = get(&a, &server, wire::SYSTEMS_PATH);
assert_eq!(r.status, 200);
check_odata_type(
"ComputerSystemCollection",
&r.body,
"schemas/ComputerSystemCollection.json",
);
check_against_schema(
"ComputerSystemCollection",
&r.body,
&fixture("schemas/ComputerSystemCollection.json"),
"ComputerSystemCollection",
);
let mock = fixture("mockup/public-rackmount1/Systems/index.json");
for key in mock.as_object().unwrap().keys().filter(|k| *k != "@Redfish.Copyright") {
assert!(
r.body.get(key).is_some(),
"our collection omits `{key}`, which DMTF's mockup carries"
);
}
assert_eq!(r.body["Members@odata.count"], 1);
assert_eq!(
r.body["Members"][0]["@odata.id"],
wire::system_path(MOCK_SYSTEM)
);
}
#[test]
fn the_computer_system_conforms_and_its_actions_block_matches_dmtfs() {
let (server, _rec) = start_server(Some("/var/lib/node.qcow2"));
let a = agent(&server);
let r = get(&a, &server, &wire::system_path(MOCK_SYSTEM));
assert_eq!(r.status, 200);
let schema = fixture("schemas/ComputerSystem.v1_22_0.json");
check_odata_type("ComputerSystem", &r.body, "schemas/ComputerSystem.v1_22_0.json");
check_against_schema("ComputerSystem", &r.body, &schema, "ComputerSystem");
check_against_schema("ComputerSystem.Boot", &r.body["Boot"], &schema, "Boot");
check_against_schema("ComputerSystem.Actions", &r.body["Actions"], &schema, "Actions");
check_against_schema(
"ComputerSystem.Actions.#ComputerSystem.Reset",
&r.body["Actions"][wire::ACTION_RESET],
&schema,
"Reset",
);
let mock = fixture("mockup/public-rackmount1/Systems/437XR1138R2/index.json");
let mock_reset = &mock["Actions"][wire::ACTION_RESET];
for key in mock_reset.as_object().unwrap().keys() {
assert!(
r.body["Actions"][wire::ACTION_RESET].get(key).is_some(),
"our Reset action omits `{key}`, which DMTF's mockup publishes"
);
}
assert_eq!(
r.body["Actions"][wire::ACTION_RESET][prop::TARGET],
wire::reset_path(MOCK_SYSTEM)
);
for key in [
prop::BOOT_SOURCE_OVERRIDE_ENABLED,
prop::BOOT_SOURCE_OVERRIDE_TARGET,
prop::BOOT_SOURCE_OVERRIDE_MODE,
] {
assert!(mock["Boot"].get(key).is_some(), "the mockup carries {key}");
assert!(r.body["Boot"].get(key).is_some(), "we omit {key}");
}
assert!(r.body["Boot"]
.get(wire::allowable_values_key(prop::BOOT_SOURCE_OVERRIDE_TARGET))
.is_some());
}
#[test]
fn the_virtual_media_resource_conforms_and_publishes_both_actions() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let coll = get(&a, &server, &wire::virtual_media_collection_path(MOCK_SYSTEM));
assert_eq!(coll.status, 200);
check_odata_type(
"VirtualMediaCollection",
&coll.body,
"schemas/VirtualMediaCollection.json",
);
check_against_schema(
"VirtualMediaCollection",
&coll.body,
&fixture("schemas/VirtualMediaCollection.json"),
"VirtualMediaCollection",
);
let r = get(&a, &server, &wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT));
assert_eq!(r.status, 200);
let schema = fixture("schemas/VirtualMedia.v1_6_3.json");
check_odata_type("VirtualMedia", &r.body, "schemas/VirtualMedia.v1_6_3.json");
check_against_schema("VirtualMedia", &r.body, &schema, "VirtualMedia");
check_against_schema("VirtualMedia.Actions", &r.body["Actions"], &schema, "Actions");
check_against_schema(
"VirtualMedia.Actions.#VirtualMedia.InsertMedia",
&r.body["Actions"][wire::ACTION_INSERT_MEDIA],
&schema,
"InsertMedia",
);
check_against_schema(
"VirtualMedia.Actions.#VirtualMedia.EjectMedia",
&r.body["Actions"][wire::ACTION_EJECT_MEDIA],
&schema,
"EjectMedia",
);
assert_eq!(
r.body["Actions"][wire::ACTION_INSERT_MEDIA][prop::TARGET],
wire::virtual_media_action_path(MOCK_SYSTEM, MOCK_SLOT, wire::INSERT_MEDIA)
);
let mock = fixture("mockup/public-rackmount1/Systems/437XR1138R2/VirtualMedia/CD1/index.json");
for key in [
"Id",
"Name",
"MediaTypes",
"ConnectedVia",
prop::IMAGE,
prop::IMAGE_NAME,
prop::INSERTED,
prop::WRITE_PROTECTED,
] {
assert!(mock.get(key).is_some(), "the mockup carries {key}");
assert!(r.body.get(key).is_some(), "we omit {key}");
}
assert_eq!(r.body["Id"], MOCK_SLOT);
}
#[test]
fn every_advertised_value_is_a_token_dmtfs_enum_declares() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let sys = get(&a, &server, &wire::system_path(MOCK_SYSTEM)).body;
let reset_enum = schema_enum(&fixture("schemas/Resource.json"), "ResetType");
let advertised: Vec<&str> = sys["Actions"][wire::ACTION_RESET]
[wire::allowable_values_key(prop::RESET_TYPE)]
.as_array()
.expect("ResetType allowable values are published")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(!advertised.is_empty());
for v in &advertised {
assert!(
reset_enum.contains(*v),
"we advertise ResetType `{v}`, which DMTF's Resource.json ResetType enum \
does not declare (DMTF has: {reset_enum:?})"
);
}
assert!(advertised.contains(&wire::RESET_ON));
assert!(advertised.contains(&wire::RESET_FORCE_OFF));
let boot_enum = schema_enum(&fixture("schemas/ComputerSystem.json"), "BootSource");
let targets: Vec<&str> = sys["Boot"][wire::allowable_values_key(prop::BOOT_SOURCE_OVERRIDE_TARGET)]
.as_array()
.expect("BootSourceOverrideTarget allowable values are published")
.iter()
.filter_map(Value::as_str)
.collect();
assert!(!targets.is_empty());
for v in &targets {
assert!(
boot_enum.contains(*v),
"we advertise BootSourceOverrideTarget `{v}`, not in DMTF's BootSource enum"
);
}
assert!(targets.contains(&wire::target_str(draupnir::BootTarget::Cd)));
assert!(targets.contains(&wire::target_str(draupnir::BootTarget::Hdd)));
let enabled_enum = schema_enum(
&fixture("schemas/ComputerSystem.v1_22_0.json"),
"BootSourceOverrideEnabled",
);
for v in wire::OVERRIDE_ENABLED_ALLOWABLE {
assert!(enabled_enum.contains(*v), "{v} is not a DMTF token");
}
assert!(enabled_enum
.contains(sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_ENABLED].as_str().unwrap()));
let power_enum = schema_enum(&fixture("schemas/Resource.json"), "PowerState");
assert!(power_enum.contains(sys[prop::POWER_STATE].as_str().unwrap()));
}
#[test]
fn every_error_message_matches_dmtfs_base_registry_entry() {
let reg = fixture("registries/Base.1.19.0.json");
assert_eq!(reg["Id"], BASE_REGISTRY);
assert!(!ALL_MESSAGES.is_empty());
for m in ALL_MESSAGES {
let entry = reg["Messages"].get(m.id).unwrap_or_else(|| {
panic!("{} is not an entry in DMTF's {BASE_REGISTRY} registry", m.id)
});
assert_eq!(entry["Message"], m.template, "{}: Message template", m.id);
assert_eq!(
entry["MessageSeverity"], m.severity,
"{}: MessageSeverity",
m.id
);
assert_eq!(
entry["NumberOfArgs"].as_u64().unwrap() as usize,
m.nargs,
"{}: NumberOfArgs",
m.id
);
assert_eq!(entry["Resolution"], m.resolution, "{}: Resolution", m.id);
assert_eq!(
m.message_id(),
format!("{BASE_REGISTRY}.{}", m.id),
"MessageId is registry-qualified"
);
}
println!(
"ANCHOR: {} error messages verified against DMTF {BASE_REGISTRY}",
ALL_MESSAGES.len()
);
}
#[test]
fn a_live_error_payload_conforms_to_the_dmtf_error_schema() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let r = post(
&a,
&server,
&wire::reset_path(MOCK_SYSTEM),
&wire::reset_body("Reboot"),
);
assert_eq!(r.status, 400);
let err_schema = fixture("schemas/redfish-error.v1_0_2.json");
check_against_schema("error payload", &r.body, &err_schema, "RedfishError");
check_against_schema(
"error contents",
&r.body["error"],
&err_schema,
"RedfishErrorContents",
);
let info = &r.body["error"]["@Message.ExtendedInfo"][0];
check_against_schema(
"extended info",
info,
&fixture("schemas/Message.v1_1_2.json"),
"Message",
);
let reg = fixture("registries/Base.1.19.0.json");
let template = reg["Messages"]["ActionParameterValueNotInList"]["Message"]
.as_str()
.unwrap();
let args: Vec<&str> = info["MessageArgs"]
.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.collect();
let mut expect = template.to_string();
for (i, arg) in args.iter().enumerate().rev() {
expect = expect.replace(&format!("%{}", i + 1), arg);
}
assert_eq!(
info["Message"].as_str().unwrap(),
expect,
"the rendered Message must be DMTF's template with MessageArgs substituted"
);
assert_eq!(r.body["error"]["message"], info["Message"]);
}
#[test]
fn a_reset_type_outside_the_advertised_list_is_refused_by_name_and_nothing_boots() {
let (server, rec) = start_server(None);
let a = agent(&server);
for bad in ["Reboot", "PowerOn", "on", "", "ForceOff "] {
let r = post(
&a,
&server,
&wire::reset_path(MOCK_SYSTEM),
&wire::reset_body(bad),
);
assert_eq!(r.status, 400, "ResetType {bad:?} must be a 400");
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.ActionParameterValueNotInList"),
"ResetType {bad:?}"
);
assert!(
r.body["error"]["message"]
.as_str()
.unwrap()
.contains(&format!("'{bad}'")),
"the refusal QUOTES the value it refused: {}",
r.body["error"]["message"]
);
assert!(r.body["error"]["message"]
.as_str()
.unwrap()
.contains(prop::RESET_TYPE));
}
assert!(
rec.last().is_none(),
"a refused ResetType must never have booted anything"
);
let r = post(&a, &server, &wire::reset_path(MOCK_SYSTEM), &serde_json::json!({}));
assert_eq!(r.status, 400);
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.ActionParameterMissing")
);
assert!(rec.last().is_none());
}
#[test]
fn insert_media_naming_an_image_that_does_not_exist_is_refused_by_name() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let insert_path = wire::virtual_media_action_path(MOCK_SYSTEM, MOCK_SLOT, wire::INSERT_MEDIA);
for ghost in [
"/nonexistent/never-built-by-anyone.iso",
"file:///nonexistent/never-built-by-anyone.iso",
] {
let r = post(&a, &server, &insert_path, &wire::insert_media_body(ghost));
assert_eq!(r.status, 400, "{ghost}");
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.ResourceMissingAtURI")
);
assert!(
r.body["error"]["message"].as_str().unwrap().contains(ghost),
"the refusal NAMES the URI: {}",
r.body["error"]["message"]
);
}
let r = post(&a, &server, &insert_path, &wire::insert_media_body("/tmp"));
assert_eq!(r.status, 400);
let vm = get(&a, &server, &wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT));
assert_eq!(vm.body[prop::INSERTED], false);
assert!(vm.body[prop::IMAGE].is_null());
assert!(server.inserted_image().is_none());
let r = post(&a, &server, &insert_path, &serde_json::json!({ "Inserted": true }));
assert_eq!(r.status, 400);
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.ActionParameterMissing")
);
assert!(r.body["error"]["message"].as_str().unwrap().contains(prop::IMAGE));
}
#[test]
fn a_boot_override_changes_what_the_node_actually_boots_not_just_the_status_code() {
let iso = probe_medium("override");
let disk = probe_medium("disk"); let (server, rec) = start_server(Some(&disk.to_string_lossy()));
let a = agent(&server);
let insert_path = wire::virtual_media_action_path(MOCK_SYSTEM, MOCK_SLOT, wire::INSERT_MEDIA);
let system_path = wire::system_path(MOCK_SYSTEM);
let reset_path = wire::reset_path(MOCK_SYSTEM);
let r = post(
&a,
&server,
&insert_path,
&wire::insert_media_body(&iso.to_string_lossy()),
);
assert_eq!(r.status, 204, "a successful action is 204 No Content");
let r = patch(
&a,
&server,
&system_path,
&wire::boot_override_body(draupnir::BootTarget::Cd),
);
assert_eq!(r.status, 204);
let sys = get(&a, &server, &system_path).body;
assert_eq!(sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_TARGET], "Cd");
assert_eq!(
sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_ENABLED],
wire::OVERRIDE_ONCE
);
assert_eq!(
post(&a, &server, &reset_path, &wire::reset_body(wire::RESET_ON)).status,
204
);
let booted = rec.last().expect("the BMC booted something");
assert_eq!(booted.boot_order, BootOrder::Medium);
assert_eq!(
booted.medium_path(),
Some(iso.to_string_lossy().as_ref()),
"the Cd override boots OFF the medium"
);
assert_eq!(server.last_boot_spec().unwrap(), booted);
assert_eq!(
post(&a, &server, &reset_path, &wire::reset_body(wire::RESET_FORCE_OFF)).status,
204
);
let r = patch(
&a,
&server,
&system_path,
&wire::boot_override_body(draupnir::BootTarget::Hdd),
);
assert_eq!(r.status, 204);
assert_eq!(
post(&a, &server, &reset_path, &wire::reset_body(wire::RESET_ON)).status,
204
);
let booted = rec.last().expect("the BMC booted something");
assert_eq!(booted.boot_order, BootOrder::Disk);
assert_eq!(
booted.medium_path(),
None,
"an Hdd override must DETACH the medium — a 204 that left the ISO attached \
would be an override accepted and never applied"
);
let vm = get(&a, &server, &wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT)).body;
assert_eq!(vm[prop::INSERTED], true);
assert_eq!(vm[prop::IMAGE], iso.to_string_lossy().as_ref());
let _ = std::fs::remove_file(&iso);
let _ = std::fs::remove_file(&disk);
}
#[test]
fn a_once_override_is_consumed_by_the_boot_it_applied_to() {
let iso = probe_medium("once");
let (server, _rec) = start_server(None);
let a = agent(&server);
let insert_path = wire::virtual_media_action_path(MOCK_SYSTEM, MOCK_SLOT, wire::INSERT_MEDIA);
post(
&a,
&server,
&insert_path,
&wire::insert_media_body(&iso.to_string_lossy()),
);
patch(
&a,
&server,
&wire::system_path(MOCK_SYSTEM),
&wire::boot_override_body(draupnir::BootTarget::Cd),
);
post(
&a,
&server,
&wire::reset_path(MOCK_SYSTEM),
&wire::reset_body(wire::RESET_ON),
);
let sys = get(&a, &server, &wire::system_path(MOCK_SYSTEM)).body;
assert_eq!(
sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_ENABLED],
wire::OVERRIDE_DISABLED
);
assert_eq!(sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_TARGET], "None");
let _ = std::fs::remove_file(&iso);
}
#[test]
fn an_unhonourable_boot_target_is_refused_rather_than_silently_substituted() {
let (server, rec) = start_server(None);
let a = agent(&server);
for target in ["Pxe", "BiosSetup", "Usb", "NotAToken"] {
let r = patch(
&a,
&server,
&wire::system_path(MOCK_SYSTEM),
&serde_json::json!({ prop::BOOT: { prop::BOOT_SOURCE_OVERRIDE_TARGET: target } }),
);
assert_eq!(r.status, 400, "{target}");
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.PropertyValueNotInList")
);
assert!(r.body["error"]["message"].as_str().unwrap().contains(target));
}
let sys = get(&a, &server, &wire::system_path(MOCK_SYSTEM)).body;
assert_eq!(sys["Boot"][prop::BOOT_SOURCE_OVERRIDE_TARGET], "None");
assert!(rec.last().is_none());
}
#[test]
fn a_wrong_method_answers_405_with_an_allow_header() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let cases = [
(
"GET",
wire::reset_path(MOCK_SYSTEM),
"POST",
),
(
"POST",
wire::system_path(MOCK_SYSTEM),
"GET, PATCH",
),
(
"PATCH",
wire::virtual_media_action_path(MOCK_SYSTEM, MOCK_SLOT, wire::INSERT_MEDIA),
"POST",
),
("PATCH", wire::SYSTEMS_PATH.to_string(), "GET"),
("POST", wire::SESSIONS_PATH.to_string(), "GET"),
];
for (method, path, allow) in cases {
let url = format!("{}{path}", server.base_url());
let req = ureq::http::Request::builder()
.method(method)
.uri(&url)
.header("Authorization", wire::basic_auth_header(USER, PASS))
.body(())
.expect("a well-formed request");
let r = exchange(a.run(req).expect("the BMC answers"));
assert_eq!(r.status, 405, "{method} {path}");
assert_eq!(r.allow.as_deref(), Some(allow), "{method} {path}");
assert_eq!(r.odata_version.as_deref(), Some("4.0"));
}
}
#[test]
fn everything_below_the_service_root_requires_a_credential() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let bare = |path: &str| {
exchange(
a.get(format!("{}{path}", server.base_url()))
.call()
.unwrap(),
)
};
assert_eq!(bare(wire::PROTOCOL_VERSION_PATH).status, 200);
assert_eq!(bare(wire::SERVICE_ROOT_PATH).status, 200);
for p in [
wire::SYSTEMS_PATH.to_string(),
wire::system_path(MOCK_SYSTEM),
wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT),
] {
let r = bare(&p);
assert_eq!(r.status, 401, "{p}");
assert!(r.www_authenticate.unwrap_or_default().starts_with("Basic "), "{p}");
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.NoValidSession"),
"{p}"
);
}
let r = exchange(
a.get(format!("{}{}", server.base_url(), wire::system_path(MOCK_SYSTEM)))
.header("Authorization", &wire::basic_auth_header(USER, "wrong"))
.call()
.unwrap(),
);
assert_eq!(r.status, 401);
}
#[test]
fn every_response_closes_its_connection_instead_of_racing_the_clients_pool() {
let (server, _rec) = start_server(None);
let a = agent(&server);
for path in [
wire::SERVICE_ROOT_PATH.to_string(),
wire::SYSTEMS_PATH.to_string(),
wire::system_path(MOCK_SYSTEM),
wire::virtual_media_path(MOCK_SYSTEM, MOCK_SLOT),
] {
let r = get(&a, &server, &path);
assert_eq!(r.status, 200, "{path}");
assert_eq!(
r.connection.as_deref().map(str::to_ascii_lowercase).as_deref(),
Some("close"),
"{path} must announce that it closes the connection"
);
}
for i in 0..60 {
let r = get(&a, &server, &wire::system_path(MOCK_SYSTEM));
assert_eq!(r.status, 200, "request {i} of a long sequential run");
}
}
#[test]
fn an_unknown_uri_is_a_dmtf_shaped_404() {
let (server, _rec) = start_server(None);
let a = agent(&server);
let r = get(&a, &server, "/redfish/v1/Systems/some-other-node");
assert_eq!(r.status, 404);
check_against_schema(
"404 payload",
&r.body,
&fixture("schemas/redfish-error.v1_0_2.json"),
"RedfishError",
);
assert_eq!(
r.body["error"]["code"],
format!("{BASE_REGISTRY}.ResourceNotFound")
);
assert!(r.body["error"]["message"]
.as_str()
.unwrap()
.contains("some-other-node"));
}
#[test]
fn the_servers_certificate_is_a_real_pinnable_identity() {
let (server, _rec) = start_server(None);
let pem = server.cert_pem();
assert!(pem.starts_with("-----BEGIN CERTIFICATE-----"), "PEM shape");
assert!(pem.contains("-----END CERTIFICATE-----"));
assert_eq!(get(&agent(&server), &server, wire::SERVICE_ROOT_PATH).status, 200);
let (other, _r2) = start_server(None);
use ureq::tls::{Certificate, RootCerts, TlsConfig};
let wrong = Certificate::from_pem(other.cert_pem().as_bytes()).unwrap();
let a: ureq::Agent = ureq::config::Config::builder()
.tls_config(TlsConfig::builder().root_certs(RootCerts::from([wrong])).build())
.http_status_as_error(false)
.build()
.into();
let err = a
.get(format!("{}{}", server.base_url(), wire::SERVICE_ROOT_PATH))
.call();
assert!(
err.is_err(),
"a client pinning a DIFFERENT cert must be refused by TLS, not served"
);
assert!(!pem.contains("PRIVATE KEY"), "the PEM handed out is the cert only");
}
#[cfg(feature = "backend-redfish")]
#[test]
fn draupnirs_own_redfish_client_drives_the_whole_burn_against_this_server() {
use draupnir::redfish::RedfishBoot;
use draupnir::{Boot, Lifecycle, VirtualMedia};
let iso = probe_medium("client");
let (server, rec) = start_server(None);
let bmc = server.bmc_endpoint();
let client = RedfishBoot::new()
.with_password(PASS)
.media_id(MOCK_SLOT)
.pin_cert_pem(server.cert_pem().as_bytes().to_vec());
let spec = BootSpec::iso_boot("conformance-burn", iso.to_string_lossy()).on_metal(bmc.clone());
let machine = client.boot(&spec).expect("the client drives the burn");
assert_eq!(machine.id, bmc.system_id);
assert_eq!(
server.inserted_image().as_deref(),
Some(iso.to_string_lossy().as_ref())
);
let booted = rec.last().expect("the node booted");
assert_eq!(booted.boot_order, BootOrder::Medium);
assert_eq!(booted.medium_path(), Some(iso.to_string_lossy().as_ref()));
let bound = RedfishBoot::for_node(bmc, PASS)
.media_id(MOCK_SLOT)
.pin_cert_pem(server.cert_pem().as_bytes().to_vec());
assert_eq!(bound.status(&machine).unwrap(), PowerState::On);
bound.power_off(&machine).expect("ForceOff");
assert_eq!(bound.status(&machine).unwrap(), PowerState::Off);
bound.eject_media(&bound_endpoint(&server)).expect("EjectMedia");
assert!(server.inserted_image().is_none());
let _ = std::fs::remove_file(&iso);
}
#[cfg(feature = "backend-redfish")]
fn bound_endpoint(server: &RedfishKvmServer) -> draupnir::BmcEndpoint {
server.bmc_endpoint()
}