#![cfg(all(feature = "embedded", feature = "web", unix))]
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpStream;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::mpsc;
use std::time::Duration;
const PATIENCE: Duration = Duration::from_secs(90);
struct Door(Child);
impl Drop for Door {
fn drop(&mut self) {
let _ = self.0.kill();
let _ = self.0.wait();
}
}
fn scratch(case: &str, config: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("iki-rl-{}-{case}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let home = dir.join("config").join("ikigai");
std::fs::create_dir_all(&home).expect("scratch config home");
std::fs::write(home.join("config.toml"), config).expect("scratch config.toml");
let files = dir.join("files");
std::fs::create_dir_all(&files).expect("scratch workspace");
std::fs::set_permissions(&files, std::os::unix::fs::PermissionsExt::from_mode(0o700))
.expect("an owner-only workspace");
dir
}
fn start(home: &Path) -> Result<(Door, String), (i32, String)> {
let mut child = Command::new(env!("CARGO_BIN_EXE_ikigai"))
.args(["serve", "--http", "0", "--cap", "urn:cap:passkey:challenge"])
.env("HOME", home)
.env("XDG_CONFIG_HOME", home.join("config"))
.env("IKIGAI_FILES", home.join("files"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.expect("the binary under test starts");
let stderr = child.stderr.take().expect("piped stderr");
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let mut seen = String::new();
let mut served = false;
let mut lines = BufReader::new(stderr).lines().map_while(Result::ok);
for line in lines.by_ref() {
seen.push_str(&line);
seen.push('\n');
if line.contains("serving HTTP on") {
served = true;
break;
}
}
let _ = tx.send((served, seen));
lines.for_each(drop);
});
let (served, seen) = rx
.recv_timeout(PATIENCE)
.unwrap_or_else(|e| panic!("the door neither served nor exited: {e}"));
if served {
return Ok((Door(child), seen));
}
let status = child.wait().expect("the door's exit status");
Err((status.code().unwrap_or(-1), seen))
}
fn bound_port(banner: &str) -> u16 {
banner
.lines()
.find_map(|line| line.split("serving HTTP on ").nth(1))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|addr| addr.parse::<std::net::SocketAddr>().ok())
.map(|addr| addr.port())
.unwrap_or_else(|| panic!("no bound address on the banner: {banner}"))
}
fn get(port: u16, path: &str) -> String {
let mut stream = TcpStream::connect(("127.0.0.1", port))
.unwrap_or_else(|e| panic!("nothing accepted on {port}: {e}"));
stream
.write_all(
format!("GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
.as_bytes(),
)
.expect("the request is written");
let mut raw = String::new();
stream
.read_to_string(&mut raw)
.expect("the response is read");
raw
}
#[test]
fn with_nothing_configured_the_door_limits_the_intake_and_the_challenge() {
let home = scratch("defaults", "");
let (_door, banner) = start(&home).unwrap_or_else(|(code, err)| panic!("{code}: {err}"));
assert!(
banner.contains(
"rate limits per client: urn:booking:submit 10/3600s, urn:contact:submit 10/3600s, \
urn:passkey:challenge 30/120s"
),
"the banner names the limits in force: {banner}"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_configured_limit_answers_429_with_retry_after_past_it() {
let home = scratch(
"configured",
"http.rate_limit = \"urn:passkey:challenge 2/3600\"\n",
);
let (_door, banner) = start(&home).unwrap_or_else(|(code, err)| panic!("{code}: {err}"));
assert!(
banner.contains("urn:passkey:challenge 2/3600s"),
"the config home's rule replaced the default: {banner}"
);
let port = bound_port(&banner);
for n in 0..2 {
let ok = get(port, "/passkey/challenge");
assert!(
ok.starts_with("HTTP/1.1 200 "),
"read {n} is in budget: {ok}"
);
assert!(ok.contains("\"challenge\""), "{ok}");
}
let refused = get(port, "/passkey/challenge");
assert!(
refused.starts_with("HTTP/1.1 429 Too Many Requests\r\n"),
"{refused}"
);
let wait: u64 = refused
.lines()
.find_map(|l| l.strip_prefix("Retry-After: "))
.unwrap_or_else(|| panic!("a Retry-After header: {refused}"))
.trim()
.parse()
.unwrap();
assert!((3500..=3600).contains(&wait), "about an hour: {refused}");
assert!(
refused.contains("rate-limited: `urn:passkey:challenge`"),
"{refused}"
);
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn a_rate_limit_line_the_door_cannot_read_stops_it_from_starting() {
for (case, config, says) in [
(
"bad-rule",
"http.rate_limit = \"urn:contact:submit ten/3600\"\n",
"is not a whole number",
),
(
"bad-key",
"http.rate_limits = \"urn:contact:submit 1/1\"\n",
"is not an HTTP rate-limit setting",
),
(
"bad-bound",
"passkey.max_challenges = 0\n",
"passkey.max_challenges = 0",
),
] {
let home = scratch(case, config);
match start(&home) {
Ok((_door, banner)) => panic!("`{config}` must stop the door, and it served: {banner}"),
Err((code, stderr)) => {
assert_eq!(code, 2, "{stderr}");
assert!(stderr.contains(says), "`{config}`: {stderr}");
assert!(
stderr.contains("config.toml"),
"the file is named: {stderr}"
);
}
}
let _ = std::fs::remove_dir_all(&home);
}
}