use crate::ServerError;
use crate::config::types::ServerConfig;
use crate::link_handshake::LocalMachine;
use lys_core::keys::Ed25519Identity;
use std::fmt::Write as _;
use zeroize::Zeroizing;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn configured(
exports: &[&str],
channels: &[(&str, bool)],
) -> Result<ServerConfig, Box<dyn std::error::Error>> {
Ok(toml::from_str(&configuration_text(exports, channels)?)?)
}
fn configuration_text(
exports: &[&str],
channels: &[(&str, bool)],
) -> Result<String, std::fmt::Error> {
let peer = LocalMachine::new(Ed25519Identity::from_seed(&Zeroizing::new([2; 32])));
let channels = if channels.is_empty() {
"channels = []\n".to_owned()
} else {
let mut text = String::new();
for (name, durable) in channels {
writeln!(text, "[[channels]]\nname = {name:?}\ndurable = {durable}")?;
}
text
};
Ok(format!(
r#"
listen_address = "127.0.0.1:8080"
health_listen_address = "127.0.0.1:8081"
drain_timeout_ms = 0
routing_rules = []
persistence_path = "{temp}"
{channels}
[auth]
token = "test-token"
[auth.lys]
issuer = "https://issuer.example"
network_id = "network-a"
keys_path = "issuer.json"
[auth.lys.machine]
identity_path = "identity.key"
pass_path = "machine.pass"
[auth.lys.machine.machines]
peer = "{}"
[[auth.lys.machine.links]]
link_id = "named-link"
peer_holder = "peer"
exports = {exports:?}
"#,
hex::encode(peer.machine().ed25519_public()),
temp = std::env::temp_dir().display()
))
}
fn named_refusal(result: Result<(), ServerError>, prefix: &str, cause: &str) -> TestResult {
let Err(ServerError::ConfigValidation { message }) = result else {
return Err(format!("expected named ConfigValidation for {prefix}: {result:?}").into());
};
assert!(message.contains("named-link"), "{message}");
assert!(
message.contains(&format!("exports '{prefix}'")),
"{message}"
);
assert!(message.contains(cause), "{message}");
Ok(())
}
fn refusal(config: &mut ServerConfig, prefix: &str, cause: &str) -> TestResult {
named_refusal(super::validate(config, None), prefix, cause)?;
let link = config
.auth
.as_ref()
.and_then(|auth| auth.lys.as_ref())
.and_then(|lys| lys.machine.as_ref())
.and_then(|machine| machine.links.first())
.ok_or("configured link absent")?;
let exports = link.exports.iter().map(String::as_str).collect::<Vec<_>>();
let channels = config
.channels
.iter()
.map(|channel| (channel.name.as_str(), channel.durable))
.collect::<Vec<_>>();
let root = tempfile::tempdir()?;
let path = root.path().join("liminal.toml");
std::fs::write(&path, configuration_text(&exports, &channels)?)?;
named_refusal(
crate::config::file::load_config(&path).map(|_| ()),
prefix,
cause,
)?;
Ok(())
}
#[test]
fn link_export_without_a_placed_subject_is_refused_by_name() -> TestResult {
let mut config = configured(&["embed", "demo"], &[("embed.result", true)])?;
refusal(&mut config, "demo", "names no placed subject")?;
let mut config = configured(&["orders"], &[("orders_other", true)])?;
refusal(&mut config, "orders", "names no placed subject")?;
Ok(())
}
#[test]
fn link_export_of_a_non_durable_channel_is_refused_by_name() -> TestResult {
let mut config = configured(&["console"], &[("console.topology", false)])?;
refusal(
&mut config,
"console",
"non-durable channel 'console.topology'",
)?;
let mut config = configured(
&["orders"],
&[("orders.created", true), ("orders.live", false)],
)?;
refusal(&mut config, "orders", "non-durable channel 'orders.live'")?;
Ok(())
}
#[test]
fn link_export_of_the_reserved_namespace_is_refused_by_name() -> TestResult {
for prefix in ["liminal", "liminal.", "liminal.cost", "liminal.cost.child"] {
let mut config = configured(&[prefix], &[("liminal.cost", true)])?;
refusal(&mut config, prefix, "reserved 'liminal.' namespace")?;
}
Ok(())
}
#[test]
fn link_export_of_durable_placed_subjects_passes_validation() -> TestResult {
let mut config = configured(
&["orders", "liminal_other"],
&[
("orders.created", true),
("liminal_other", true),
("orders_other", false),
],
)?;
super::validate(&mut config, None)?;
Ok(())
}
fn service_configuration(
exports: &[&str],
channels: &[(&str, bool)],
section: &str,
) -> Result<String, std::fmt::Error> {
let text = configuration_text(exports, channels)?.replace(
"keys_path = \"issuer.json\"",
"keys_path = \"issuer.json\"\napp_credential_path = \"app.credential\"",
);
Ok(format!("{text}\n{section}"))
}
fn validate_service_text(text: &str, prefix: &str, subject: &str) -> TestResult {
let mut config: ServerConfig = toml::from_str(text)?;
let cause = format!("names service subject '{subject}', which is not a stream");
named_refusal(super::validate(&mut config, None), prefix, &cause)?;
let root = tempfile::tempdir()?;
let path = root.path().join("liminal.toml");
std::fs::write(&path, text)?;
named_refusal(
crate::config::file::load_config(&path).map(|_| ()),
prefix,
&cause,
)?;
Ok(())
}
const EMBED_TEMPLATE: &str = r#"
[[auth.lys.templates]]
name = "embed"
command = "/bin/sh"
subjects = ["embed"]
stop_timeout_ms = 500
"#;
#[test]
fn link_export_of_a_template_service_is_refused_as_not_a_stream() -> TestResult {
for prefix in ["embed", "embed.bge"] {
let text = service_configuration(&[prefix], &[], EMBED_TEMPLATE)?;
validate_service_text(&text, prefix, prefix)?;
}
let mut config: ServerConfig = toml::from_str(&service_configuration(
&["embed_other", "embed.bge.extra"],
&[],
EMBED_TEMPLATE,
)?)?;
let Err(ServerError::ConfigValidation { message }) = super::validate(&mut config, None) else {
return Err("unserved subject was accepted".into());
};
for prefix in ["embed_other", "embed.bge.extra"] {
assert!(
message.contains(&format!("exports '{prefix}' names no placed subject")),
"{message}"
);
}
Ok(())
}
#[test]
fn link_export_of_a_service_and_durable_channel_exports_the_stream() -> TestResult {
validate_service_text(
&service_configuration(&["embed"], &[], EMBED_TEMPLATE)?,
"embed",
"embed",
)?;
for channel in ["embed.result", "embed"] {
let text = service_configuration(&["embed"], &[(channel, true)], EMBED_TEMPLATE)?;
let root = tempfile::tempdir()?;
let path = root.path().join("liminal.toml");
std::fs::write(&path, text)?;
crate::config::file::load_config(&path)?;
}
Ok(())
}
#[test]
fn link_export_of_a_service_and_non_durable_channel_names_both_causes() -> TestResult {
let text = service_configuration(&["embed"], &[("embed.live", false)], EMBED_TEMPLATE)?;
validate_service_text(&text, "embed", "embed")?;
let mut config: ServerConfig = toml::from_str(&text)?;
named_refusal(
super::validate(&mut config, None),
"embed",
"non-durable channel 'embed.live'",
)?;
Ok(())
}
#[test]
fn link_export_of_configured_administrative_services_is_not_a_stream() -> TestResult {
let section = "[auth.lys.services]\nmachine = 'local'\nhost = 'host'";
for (prefix, subject) in [
("alarms", "alarms"),
("seats", "seats.<name>"),
("seats.agent", "seats.agent"),
("sessions.session", "sessions.session.keys"),
("sessions.session.keys", "sessions.session.keys"),
] {
validate_service_text(
&service_configuration(&[prefix], &[], section)?,
prefix,
subject,
)?;
}
let mut config = configured(&["alarms"], &[])?;
named_refusal(
super::validate(&mut config, None),
"alarms",
"names no placed subject",
)?;
Ok(())
}
#[test]
fn link_export_of_configured_broker_and_relay_services_is_not_a_stream() -> TestResult {
for (prefix, section, subject) in [
(
"net.broker",
"[auth.lys.broker]\nudp_listen = '127.0.0.1:8090'",
"net.broker.register",
),
("net.relay", "[auth.lys.relay]\nenabled = true", "net.relay"),
] {
validate_service_text(
&service_configuration(&[prefix], &[], section)?,
prefix,
subject,
)?;
}
let mut config: ServerConfig = toml::from_str(&service_configuration(
&["net.relay"],
&[],
"[auth.lys.relay]\nenabled = false",
)?)?;
named_refusal(
super::validate(&mut config, None),
"net.relay",
"names no placed subject",
)?;
Ok(())
}
#[test]
fn link_export_of_template_lifecycle_calls_is_not_a_stream() -> TestResult {
let text = service_configuration(&["services.list"], &[], EMBED_TEMPLATE)?;
validate_service_text(&text, "services.list", "services.list")?;
Ok(())
}