use std::cell::Cell;
use std::time::Duration;
use super::super::{Held, LysAgent, LysTable, PKCS8_ED25519_PREFIX, PassError};
use base64::Engine as _;
use std::io::Write as _;
type TestResult = Result<(), Box<dyn std::error::Error>>;
fn table() -> Result<LysTable, toml::de::Error> {
toml::from_str("issuer = 'https://lys.example'")
}
fn source(agent: Option<&str>, pass: Option<&str>) -> Result<LysAgent, &'static str> {
let table = table().map_err(|_| "table did not parse")?;
LysAgent::new_with_env(&table, Duration::from_secs(1), |name| {
Ok(match name {
"LYS_AGENT" => agent.map(str::to_owned),
"LYS_AGENT_PASS" => pass.map(str::to_owned),
_ => None,
})
})
}
fn answer(audience: &str, expires_at: u64, pass: &str) -> Result<(u16, Vec<u8>), PassError> {
serde_json::to_vec(&serde_json::json!({
"pass": pass, "expires_at": expires_at, "audience": audience,
}))
.map(|body| (200, body))
.map_err(|_| PassError("fixture answer did not encode".to_owned()))
}
#[test]
fn a_ticket_without_a_key_file_asks_with_the_run_pass_header() -> TestResult {
let agent = source(Some("agent-1"), Some("fixture-run-pass"))?;
let pass = agent.pass_for_at("liminal", 100, |route, request| {
assert_eq!(route, "https://lys.example/api/agents/agent-1/pass");
assert_eq!(request.header, "lys-agent-pass");
assert_eq!(request.proof, "fixture-run-pass");
let header = request.proof_header()?;
assert!(header.is_sensitive());
assert!(!format!("{header:?}").contains("fixture-run-pass"));
assert_eq!(request.body, br#"{"audience":"liminal"}"#);
answer("liminal", 120, "fixture-app-pass")
})?;
assert_eq!(pass, "fixture-app-pass");
assert!(!format!("{agent:?}").contains("fixture-run-pass"));
Ok(())
}
#[test]
fn a_missing_lys_agent_pass_is_refused_by_name() {
for absent in [None, Some(""), Some(" ")] {
assert_eq!(
source(Some("agent-1"), absent).err(),
Some("LYS_AGENT_PASS is missing or empty")
);
}
}
#[test]
fn a_missing_lys_agent_is_refused_by_name() {
for absent in [None, Some(""), Some(" ")] {
assert_eq!(
source(absent, Some("fixture-run-pass")).err(),
Some("LYS_AGENT is missing or empty")
);
}
}
#[test]
fn a_table_agent_that_differs_from_lys_agent_is_refused_by_name() -> TestResult {
let table: LysTable = toml::from_str("issuer = 'https://lys.example'\nagent = 'agent-2'")?;
let refused = LysAgent::new_with_env(&table, Duration::from_secs(1), |name| {
Ok(Some(
if name == "LYS_AGENT" {
"agent-1"
} else {
"fixture-run-pass"
}
.to_owned(),
))
});
assert_eq!(refused.err(), Some("lys.agent differs from LYS_AGENT"));
Ok(())
}
#[test]
fn a_run_pass_is_held_until_expiry_and_asked_again_per_audience() -> TestResult {
let agent = source(Some("agent-1"), Some("fixture-run-pass"))?;
let asks = Cell::new(0);
let ask = |_: &str, request: &super::PassRequest| {
asks.set(asks.get() + 1);
let body: serde_json::Value = serde_json::from_slice(&request.body)
.map_err(|_| PassError("fixture request did not read".to_owned()))?;
let audience = body["audience"]
.as_str()
.ok_or_else(|| PassError("audience absent".to_owned()))?;
answer(audience, 120 + asks.get(), &format!("pass-{}", asks.get()))
};
assert_eq!(agent.pass_for_at("liminal", 100, ask)?, "pass-1");
assert_eq!(agent.pass_for_at("liminal", 120, ask)?, "pass-1");
assert_eq!(asks.get(), 1);
assert_eq!(agent.pass_for_at("haematite", 120, ask)?, "pass-2");
assert_eq!(agent.pass_for_at("liminal", 121, ask)?, "pass-3");
assert_eq!(asks.get(), 3);
Ok(())
}
#[test]
fn a_run_pass_refusal_is_named_without_revealing_secrets() -> TestResult {
let agent = source(Some("agent-1"), Some("fixture-run-pass"))?;
let error = agent
.pass_for_at("liminal", 100, |_, _| {
Ok((
403,
br#"{"refusal":"agent_pass_unproven","detail":"fixture-run-pass"}"#.to_vec(),
))
})
.err()
.ok_or("refusal was accepted")?;
assert_eq!(
error.0,
"Lys refused the agent pass: agent_pass_unproven (HTTP 403)"
);
Ok(())
}
#[test]
fn a_run_pass_never_enters_a_transport_error() -> TestResult {
let agent = source(Some("agent-1"), Some("fixture-run-pass"))?;
let error = agent
.pass_for_at("liminal", 100, |_, _| {
Err(PassError("Lys transport unavailable".to_owned()))
})
.err()
.ok_or("transport error was accepted")?;
assert_eq!(error.0, "Lys transport unavailable");
Ok(())
}
#[test]
fn a_table_with_a_key_file_signs_as_today() -> TestResult {
let seed = [7_u8; 32];
let der = [PKCS8_ED25519_PREFIX.as_slice(), seed.as_slice()].concat();
let mut key = tempfile::NamedTempFile::new()?;
write!(
key,
"-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n",
base64::engine::general_purpose::STANDARD.encode(der)
)?;
let table = LysTable {
issuer: "https://lys.example".to_owned(),
agent: "agent-1".to_owned(),
key_file: key.path().to_owned(),
};
let agent = LysAgent::new_with_env(&table, Duration::from_secs(1), |_| {
Err("the signed route must not read the run environment")
})?;
agent.pass_for_at("liminal", 100, |_, request| {
assert_eq!(request.header, "lys-agent-signature");
assert!(request.proof.starts_with("agent-1 100000 "));
assert_eq!(request.body, br#"{"audience":"liminal"}"#);
answer("liminal", 200, "fixture-app-pass")
})?;
Ok(())
}
#[test]
fn an_explicit_empty_key_or_agent_is_refused_in_the_table() {
for field in ["key_file = ''", "agent = ''"] {
assert!(
toml::from_str::<LysTable>(&format!("issuer = 'https://lys.example'\n{field}"))
.is_err()
);
}
}
#[test]
fn an_expired_admission_retires_only_the_liminal_pass() -> TestResult {
let agent = source(Some("agent-1"), Some("fixture-run-pass"))?;
for audience in ["liminal", "haematite"] {
agent.pass_for_at(audience, 100, |_, _| answer(audience, 120, audience))?;
}
assert!(agent.forget_expired_pass()?);
assert_eq!(
agent.pass_for_at("haematite", 110, |_, _| {
Err(PassError("another audience is still held".to_owned()))
})?,
"haematite"
);
assert_eq!(
agent.pass_for_at("liminal", 110, |_, _| answer("liminal", 150, "fresh"))?,
"fresh"
);
Ok(())
}
#[test]
fn an_invalid_run_agent_or_header_is_refused_without_its_value() {
assert_eq!(
source(Some("agent/secret"), Some("fixture-run-pass")).err(),
Some("LYS_AGENT must be a plain agent id")
);
assert_eq!(
source(Some("agent-1"), Some("secret\nvalue")).err(),
Some("LYS_AGENT_PASS is not a valid header value")
);
}
#[test]
fn transport_failure_names_the_cause_without_echoing_credential_text() {
let error = ureq::Error::Other("fixture-run-pass".into());
assert_eq!(super::transport_reason(&error), "other transport failure");
let error = ureq::Error::Io(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"fixture-run-pass",
));
assert_eq!(super::transport_reason(&error), "I/O: permission denied");
}
#[test]
fn a_signed_source_is_unchanged_by_run_pass_admission_renewal() -> TestResult {
let table = LysTable {
issuer: "https://lys.example".to_owned(),
agent: "agent-1".to_owned(),
key_file: "/unused-key".into(),
};
let agent = LysAgent::new_with_env(&table, Duration::from_secs(1), |_| {
Err("a signed source does not read run variables")
})?;
agent.lock()?.insert(
"liminal".to_owned(),
Held {
pass: "held-signed-pass".to_owned(),
expires_at: 200,
},
);
assert!(!agent.forget_expired_pass()?);
assert_eq!(
agent.pass_for_at("liminal", 100, |_, _| {
Err(PassError("the signed pass is still held".to_owned()))
})?,
"held-signed-pass"
);
Ok(())
}