use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};
use serde::Deserialize;
pub const ENGINE_PROTOCOL: u32 = 1;
pub const DEFAULT_MGMT_BASE: &str = "http://127.0.0.1:8765";
const CALL_TIMEOUT: Duration = Duration::from_millis(1500);
fn client() -> reqwest::blocking::Client {
reqwest::blocking::Client::new()
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct BillingInfo {
#[serde(default)]
pub entitled: bool,
#[serde(default)]
pub status: String,
#[serde(default)]
pub trial_ends_at: Option<i64>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct EngineInfo {
#[serde(default)]
pub engine_version: String,
#[serde(default)]
pub protocol: u32,
#[serde(default)]
pub pid: u32,
#[serde(default)]
pub mode: String,
#[serde(default)]
pub connected: bool,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub first_party_app: Option<String>,
#[serde(default)]
pub registrants: u32,
#[serde(default)]
pub billing: Option<BillingInfo>,
}
impl EngineInfo {
pub fn needs_renewal(&self) -> bool {
matches!(
self.billing.as_ref().map(|b| b.status.as_str()),
Some("hold") | Some("past_due")
)
}
}
pub fn discover(mgmt_base: &str) -> Option<EngineInfo> {
client()
.get(format!("{mgmt_base}/engine/info"))
.timeout(CALL_TIMEOUT)
.send()
.ok()?
.json::<EngineInfo>()
.ok()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StartAction {
Attach,
Takeover,
}
pub fn decide_start_action(info: Option<&EngineInfo>, protocol: u32) -> StartAction {
match info {
Some(i) if i.protocol == protocol => StartAction::Attach,
_ => StartAction::Takeover,
}
}
pub fn register(mgmt_base: &str, app_id: &str, pid: u32) -> reqwest::Result<()> {
let res = client()
.post(format!("{mgmt_base}/engine/register"))
.json(&serde_json::json!({ "appId": app_id, "pid": pid }))
.timeout(CALL_TIMEOUT)
.send()?;
if let Ok(body) = res.json::<RegisterReply>() {
if let Some(cap) = body.capability {
store_capability(cap);
}
}
Ok(())
}
#[derive(Debug, Deserialize)]
struct RegisterReply {
capability: Option<String>,
}
static CAPABILITY: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
fn store_capability(cap: String) {
if let Ok(mut slot) = CAPABILITY.write() {
*slot = Some(cap);
}
}
fn capability() -> Option<String> {
CAPABILITY.read().ok().and_then(|slot| slot.clone())
}
pub fn heartbeat(mgmt_base: &str, app_id: &str, pid: u32) {
let _ = client()
.post(format!("{mgmt_base}/engine/heartbeat"))
.json(&serde_json::json!({ "appId": app_id, "pid": pid }))
.timeout(CALL_TIMEOUT)
.send();
}
pub fn deregister(mgmt_base: &str, app_id: &str) {
let _ = client()
.post(format!("{mgmt_base}/engine/deregister"))
.json(&serde_json::json!({ "appId": app_id }))
.timeout(CALL_TIMEOUT)
.send();
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct PublishResult {
#[serde(default)]
pub status: String,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub url: Option<String>,
}
pub fn publish(
mgmt_base: &str,
name: &str,
label: &str,
local_port: u16,
app_id: Option<&str>,
) -> reqwest::Result<PublishResult> {
let mut body = serde_json::json!({ "name": name, "label": label, "localPort": local_port });
if let Some(id) = app_id {
body["appId"] = serde_json::Value::String(id.to_string());
}
client()
.post(format!("{mgmt_base}/publish"))
.json(&body)
.timeout(CALL_TIMEOUT)
.send()?
.json::<PublishResult>()
}
pub fn publish_status(mgmt_base: &str, name: &str) -> reqwest::Result<PublishResult> {
client()
.get(format!("{mgmt_base}/publish/{name}"))
.timeout(CALL_TIMEOUT)
.send()?
.json::<PublishResult>()
}
pub fn unpublish(mgmt_base: &str, name: &str) {
let _ = client()
.delete(format!("{mgmt_base}/publish/{name}"))
.timeout(CALL_TIMEOUT)
.send();
}
pub fn status(mgmt_base: &str) -> Option<serde_json::Value> {
client()
.get(format!("{mgmt_base}/status"))
.timeout(CALL_TIMEOUT)
.send()
.ok()?
.json::<serde_json::Value>()
.ok()
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Person {
pub email: String,
pub account_id: String,
pub role: String,
pub status: String,
#[serde(default)]
pub apps: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct People {
#[serde(default)]
pub name: String,
#[serde(default)]
pub members: Vec<Person>,
#[serde(default)]
pub published_apps: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum PeopleError {
#[error("not attached to an engine yet")]
NotAttached,
#[error("{0}")]
Refused(String),
#[error("could not reach the sharing service")]
Unreachable,
}
fn people_call(
mgmt_base: &str,
method: reqwest::Method,
path: &str,
body: Option<serde_json::Value>,
) -> Result<serde_json::Value, PeopleError> {
let cap = capability().ok_or(PeopleError::NotAttached)?;
let mut req = client()
.request(method, format!("{mgmt_base}{path}"))
.header("x-engine-capability", cap)
.timeout(CALL_TIMEOUT);
if let Some(b) = body {
req = req.json(&b);
}
let res = req.send().map_err(|_| PeopleError::Unreachable)?;
let status = res.status();
let parsed: serde_json::Value = res.json().unwrap_or(serde_json::Value::Null);
if status.is_success() {
return Ok(parsed);
}
Err(PeopleError::Refused(
parsed
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("that did not work")
.to_string(),
))
}
pub fn people(mgmt_base: &str) -> Result<People, PeopleError> {
let raw = people_call(mgmt_base, reqwest::Method::GET, "/people", None)?;
serde_json::from_value(raw).map_err(|_| PeopleError::Refused("unexpected reply".into()))
}
pub fn invite(mgmt_base: &str, email: &str, apps: &[String]) -> Result<(), PeopleError> {
people_call(
mgmt_base,
reqwest::Method::POST,
"/people/invite",
Some(serde_json::json!({ "email": email, "apps": apps })),
)
.map(|_| ())
}
pub fn grant(
mgmt_base: &str,
account_id: &str,
app: &str,
granted: bool,
) -> Result<(), PeopleError> {
people_call(
mgmt_base,
reqwest::Method::POST,
"/people/grant",
Some(serde_json::json!({ "accountId": account_id, "app": app, "granted": granted })),
)
.map(|_| ())
}
pub fn revoke(mgmt_base: &str, account_id: &str) -> Result<(), PeopleError> {
people_call(
mgmt_base,
reqwest::Method::POST,
"/people/revoke",
Some(serde_json::json!({ "accountId": account_id })),
)
.map(|_| ())
}
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub node_bin: PathBuf,
pub agent_path: PathBuf,
pub mode: String,
pub device_token: String,
pub relay_addr: String,
pub control_plane: String,
pub local_port: u16,
pub mgmt_secret: String,
pub frp_token: Option<String>,
pub frpc_bin: Option<PathBuf>,
pub cert_mode: Option<String>,
pub first_party_app: Option<String>,
pub engine_version: Option<String>,
pub work_dir: Option<PathBuf>,
pub mgmt_port: Option<u16>,
}
impl EngineConfig {
pub fn portal(
node_bin: PathBuf,
agent_path: PathBuf,
device_token: String,
relay_addr: String,
control_plane: String,
mgmt_secret: String,
) -> Self {
EngineConfig {
node_bin,
agent_path,
mode: "portal".into(),
device_token,
relay_addr,
control_plane,
local_port: 8443,
mgmt_secret,
frp_token: None,
frpc_bin: None,
cert_mode: None,
first_party_app: None,
engine_version: None,
work_dir: None,
mgmt_port: None,
}
}
pub fn to_args(&self) -> Vec<String> {
let mut a: Vec<String> = vec![
"--mode".into(),
self.mode.clone(),
"--relay-addr".into(),
self.relay_addr.clone(),
"--control-plane".into(),
self.control_plane.clone(),
"--local-port".into(),
self.local_port.to_string(),
];
if let Some(fb) = &self.frpc_bin {
a.push("--frpc-bin".into());
a.push(fb.display().to_string());
}
if let Some(cm) = &self.cert_mode {
a.push("--cert-mode".into());
a.push(cm.clone());
}
if let Some(fp) = &self.first_party_app {
a.push("--first-party-app".into());
a.push(fp.clone());
}
if let Some(ev) = &self.engine_version {
a.push("--engine-version".into());
a.push(ev.clone());
}
if let Some(wd) = &self.work_dir {
a.push("--work-dir".into());
a.push(wd.display().to_string());
}
if let Some(mp) = self.mgmt_port {
a.push("--mgmt-port".into());
a.push(mp.to_string());
}
a
}
pub fn to_envs(&self) -> Vec<(String, String)> {
let mut e = Vec::new();
if !self.device_token.is_empty() {
e.push(("AGENT_DEVICE_TOKEN".into(), self.device_token.clone()));
}
if !self.mgmt_secret.is_empty() {
e.push(("AGENT_MGMT_SECRET".into(), self.mgmt_secret.clone()));
}
if let Some(ft) = self.frp_token.as_ref().filter(|s| !s.is_empty()) {
e.push(("AGENT_FRP_TOKEN".into(), ft.clone()));
}
e
}
pub fn command(&self) -> Command {
let mut c = Command::new(&self.node_bin);
c.arg(&self.agent_path);
c.args(self.to_args());
c.envs(self.to_envs());
c
}
pub fn spawn(&self) -> std::io::Result<Child> {
let mut c = self.command();
c.stdout(Stdio::null()).stderr(Stdio::null());
c.spawn()
}
pub fn mgmt_base(&self) -> String {
format!("http://127.0.0.1:{}", self.mgmt_port.unwrap_or(8765))
}
}
pub enum StartOutcome {
Attached(EngineInfo),
Spawned(Child),
}
pub fn spawn_or_attach(
cfg: &EngineConfig,
app_id: &str,
pid: u32,
) -> std::io::Result<StartOutcome> {
let base = cfg.mgmt_base();
if let Some(info) = discover(&base) {
if decide_start_action(Some(&info), ENGINE_PROTOCOL) == StartAction::Attach {
let _ = register(&base, app_id, pid);
return Ok(StartOutcome::Attached(info));
}
}
Ok(StartOutcome::Spawned(cfg.spawn()?))
}
pub fn wait_engine(mgmt_base: &str, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
loop {
if discover(mgmt_base).is_some() {
return true;
}
if Instant::now() >= deadline {
return false;
}
std::thread::sleep(Duration::from_millis(200));
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DeviceCode {
pub code: String,
pub verify_url: String,
}
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(rename_all = "camelCase")]
pub struct Exchange {
#[serde(default)]
pub status: String, #[serde(default)]
pub device_token: Option<String>,
#[serde(default)]
pub host: Option<String>,
}
#[derive(Debug)]
pub enum ConnectError {
Http(reqwest::Error),
Io(std::io::Error),
CodeExpired,
Timeout,
NoCredential,
EngineUnreachable,
}
impl std::fmt::Display for ConnectError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConnectError::Http(e) => write!(f, "network error: {e}"),
ConnectError::Io(e) => write!(f, "spawn error: {e}"),
ConnectError::CodeExpired => write!(f, "the approval code expired — please try again"),
ConnectError::Timeout => write!(f, "timed out waiting for approval in the browser"),
ConnectError::NoCredential => write!(f, "approval returned no credential"),
ConnectError::EngineUnreachable => write!(f, "the engine did not come up in time"),
}
}
}
impl std::error::Error for ConnectError {}
impl From<reqwest::Error> for ConnectError {
fn from(e: reqwest::Error) -> Self {
ConnectError::Http(e)
}
}
impl From<std::io::Error> for ConnectError {
fn from(e: std::io::Error) -> Self {
ConnectError::Io(e)
}
}
pub fn request_device_code(control_plane: &str) -> reqwest::Result<DeviceCode> {
client()
.post(format!("{control_plane}/device/code"))
.timeout(Duration::from_secs(10))
.send()?
.json::<DeviceCode>()
}
pub fn poll_exchange(
control_plane: &str,
code: &str,
timeout: Duration,
interval: Duration,
) -> Result<Exchange, ConnectError> {
let deadline = Instant::now() + timeout;
loop {
let ex: Exchange = client()
.get(format!("{control_plane}/device/exchange?code={code}"))
.timeout(Duration::from_secs(10))
.send()?
.json()?;
match ex.status.as_str() {
"approved" => return Ok(ex),
"unknown" => return Err(ConnectError::CodeExpired),
_ => {}
}
if Instant::now() >= deadline {
return Err(ConnectError::Timeout);
}
std::thread::sleep(interval);
}
}
pub struct ConnectRequest<'a> {
pub control_plane: &'a str,
pub app_id: &'a str,
pub publish_name: &'a str,
pub publish_label: &'a str,
pub local_port: u16,
pub poll_timeout: Duration,
}
pub struct Connected {
pub device_token: String,
pub host: String,
pub publish: PublishResult,
pub attached: bool,
}
pub fn connect<O, P, B>(
req: &ConnectRequest,
pid: u32,
open_url: O,
persist: P,
build_config: B,
) -> Result<Connected, ConnectError>
where
O: FnOnce(&str),
P: FnOnce(&str, &str),
B: FnOnce(&str) -> EngineConfig,
{
let dc = request_device_code(req.control_plane)?;
open_url(&dc.verify_url);
let ex = poll_exchange(req.control_plane, &dc.code, req.poll_timeout, Duration::from_secs(2))?;
let token = ex.device_token.ok_or(ConnectError::NoCredential)?;
let host = ex.host.unwrap_or_default();
persist(&token, &host);
let cfg = build_config(&token);
let base = cfg.mgmt_base();
let outcome = spawn_or_attach(&cfg, req.app_id, pid)?;
let attached = matches!(outcome, StartOutcome::Attached(_));
if !wait_engine(&base, Duration::from_secs(30)) {
return Err(ConnectError::EngineUnreachable);
}
let publish = publish(&base, req.publish_name, req.publish_label, req.local_port, Some(req.app_id))?;
Ok(Connected { device_token: token, host, publish, attached })
}
#[cfg(test)]
mod tests {
use super::*;
fn info(protocol: u32) -> EngineInfo {
EngineInfo { protocol, ..Default::default() }
}
#[test]
fn attach_only_on_matching_protocol() {
assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL)), ENGINE_PROTOCOL), StartAction::Attach);
assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL + 1)), ENGINE_PROTOCOL), StartAction::Takeover);
assert_eq!(decide_start_action(Some(&info(0)), ENGINE_PROTOCOL), StartAction::Takeover);
}
#[test]
fn takeover_when_no_engine_answers() {
assert_eq!(decide_start_action(None, ENGINE_PROTOCOL), StartAction::Takeover);
}
#[test]
fn engine_info_parses_camelcase() {
let j = r#"{"engineVersion":"0.4.0","protocol":1,"pid":42,"mode":"portal",
"connected":true,"host":"alice.meradomo.com","name":"alice",
"firstPartyApp":"com.example.app","registrants":2}"#;
let i: EngineInfo = serde_json::from_str(j).unwrap();
assert_eq!(i.engine_version, "0.4.0");
assert_eq!(i.protocol, 1);
assert_eq!(i.pid, 42);
assert_eq!(i.connected, true);
assert_eq!(i.name.as_deref(), Some("alice"));
assert_eq!(i.first_party_app.as_deref(), Some("com.example.app"));
assert_eq!(i.registrants, 2);
}
#[test]
fn bare_config_emits_exactly_the_portal_flags() {
let cfg = EngineConfig::portal(
"node".into(),
"agent.mjs".into(),
"tok".into(),
"relay:7000".into(),
"http://cp:9002".into(),
"secret".into(),
);
let args = cfg.to_args();
assert_eq!(
args,
vec![
"--mode", "portal",
"--relay-addr", "relay:7000",
"--control-plane", "http://cp:9002",
"--local-port", "8443",
]
);
}
#[test]
fn secrets_travel_by_env_never_argv() {
let mut cfg = EngineConfig::portal(
"node".into(),
"agent.mjs".into(),
"device-tok".into(),
"relay:7000".into(),
"http://cp:9002".into(),
"owner-secret".into(),
);
cfg.frp_token = Some("relay-tok".into());
let joined = cfg.to_args().join(" ");
for secret in ["device-tok", "owner-secret", "relay-tok"] {
assert!(!joined.contains(secret), "argv leaked {secret}: {joined}");
}
let envs = cfg.to_envs();
assert!(envs.contains(&("AGENT_DEVICE_TOKEN".into(), "device-tok".into())));
assert!(envs.contains(&("AGENT_MGMT_SECRET".into(), "owner-secret".into())));
assert!(envs.contains(&("AGENT_FRP_TOKEN".into(), "relay-tok".into())));
cfg.mgmt_secret = String::new();
cfg.frp_token = None;
let envs = cfg.to_envs();
assert_eq!(envs.len(), 1, "only the device token remains: {envs:?}");
}
#[test]
fn optional_flags_appear_only_when_set() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
cfg.frpc_bin = Some("/side/frpc".into());
cfg.cert_mode = Some("acme".into());
cfg.first_party_app = Some("com.example.app".into());
cfg.engine_version = Some("0.4.0".into());
let args = cfg.to_args();
assert!(args.windows(2).any(|w| w == ["--frpc-bin", "/side/frpc"]));
assert!(args.windows(2).any(|w| w == ["--cert-mode", "acme"]));
assert!(args.windows(2).any(|w| w == ["--first-party-app", "com.example.app"]));
assert!(args.windows(2).any(|w| w == ["--engine-version", "0.4.0"]));
}
#[test]
fn empty_frp_token_is_omitted() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
cfg.frp_token = Some(String::new());
assert!(!cfg.to_envs().iter().any(|(k, _)| k == "AGENT_FRP_TOKEN"));
}
#[test]
fn mgmt_base_reflects_port() {
let mut cfg = EngineConfig::portal(
"node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
);
assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8765");
cfg.mgmt_port = Some(8790);
assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8790");
}
#[test]
fn device_code_parses() {
let dc: DeviceCode = serde_json::from_str(
r#"{"code":"abc123","verifyUrl":"https://account.meradomo.com/device/approve?code=abc123"}"#,
)
.unwrap();
assert_eq!(dc.code, "abc123");
assert!(dc.verify_url.contains("device/approve"));
}
#[test]
fn exchange_pending_then_approved() {
let pending: Exchange = serde_json::from_str(r#"{"status":"pending"}"#).unwrap();
assert_eq!(pending.status, "pending");
assert!(pending.device_token.is_none());
let approved: Exchange = serde_json::from_str(
r#"{"status":"approved","deviceToken":"tok-xyz","host":"alice.meradomo.com"}"#,
)
.unwrap();
assert_eq!(approved.status, "approved");
assert_eq!(approved.device_token.as_deref(), Some("tok-xyz"));
assert_eq!(approved.host.as_deref(), Some("alice.meradomo.com"));
}
#[test]
fn connect_error_messages_are_human() {
assert!(ConnectError::Timeout.to_string().contains("browser"));
assert!(ConnectError::CodeExpired.to_string().contains("expired"));
assert!(ConnectError::EngineUnreachable.to_string().contains("engine"));
}
#[test]
fn needs_renewal_only_on_lapse() {
let mk = |s: &str| EngineInfo {
billing: Some(BillingInfo { status: s.into(), ..Default::default() }),
..Default::default()
};
assert!(mk("hold").needs_renewal());
assert!(mk("past_due").needs_renewal());
assert!(!mk("active").needs_renewal());
assert!(!mk("trialing").needs_renewal());
assert!(!mk("comp").needs_renewal());
assert!(!EngineInfo::default().needs_renewal());
}
#[test]
fn engine_info_parses_billing() {
let j = r#"{"protocol":1,"billing":{"entitled":false,"status":"hold","trialEndsAt":123}}"#;
let i: EngineInfo = serde_json::from_str(j).unwrap();
let b = i.billing.as_ref().unwrap();
assert_eq!(b.entitled, false);
assert_eq!(b.status, "hold");
assert_eq!(b.trial_ends_at, Some(123));
assert!(i.needs_renewal());
}
use std::io::{BufRead, BufReader, Read, Write};
use std::net::TcpListener;
use std::sync::mpsc;
fn one_shot(status: u16, reply: &str) -> (String, mpsc::Receiver<(String, String, String)>) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
let base = format!("http://{}", listener.local_addr().unwrap());
let (tx, rx) = mpsc::channel();
let reply = reply.to_string();
std::thread::spawn(move || {
let (mut sock, _) = listener.accept().expect("accept");
let mut reader = BufReader::new(sock.try_clone().unwrap());
let mut start = String::new();
reader.read_line(&mut start).ok();
let mut headers = String::new();
let mut len = 0usize;
loop {
let mut line = String::new();
if reader.read_line(&mut line).unwrap_or(0) == 0 { break; }
if line.trim().is_empty() { break; }
if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
len = v.trim().parse().unwrap_or(0);
}
headers.push_str(&line);
}
let mut body = vec![0u8; len];
if len > 0 { reader.read_exact(&mut body).ok(); }
tx.send((
start.trim().to_string(),
headers,
String::from_utf8_lossy(&body).to_string(),
)).ok();
let out = format!(
"HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}",
reply.len()
);
sock.write_all(out.as_bytes()).ok();
sock.flush().ok();
});
(base, rx)
}
#[test]
fn people_surface() {
if let Ok(mut slot) = CAPABILITY.write() { *slot = None; }
let err = people("http://127.0.0.1:1").unwrap_err();
assert!(matches!(err, PeopleError::NotAttached),
"an app that never registered must not be able to manage people");
let (base, rx) = one_shot(200, r#"{"ok":true,"capability":"cap-xyz-123456789012345"}"#);
register(&base, "com.example.app", 42).expect("register");
let (start, _h, body) = rx.recv().expect("no request arrived");
assert!(start.starts_with("POST /engine/register"), "{start}");
assert!(body.contains("com.example.app"));
assert_eq!(capability().as_deref(), Some("cap-xyz-123456789012345"));
let (base, rx) = one_shot(
200,
r#"{"name":"example","members":[{"email":"a@b.c","accountId":"acc1","role":"member","status":"active","apps":["Music"]}],"publishedApps":["Music"]}"#,
);
let got = people(&base).expect("people");
let (start, headers, _b) = rx.recv().unwrap();
assert!(start.starts_with("GET /people"), "{start}");
assert!(headers.to_lowercase().contains("x-engine-capability: cap-xyz-123456789012345"),
"the capability header was not sent: {headers}");
assert_eq!(got.name, "example");
assert_eq!(got.members.len(), 1);
assert_eq!(got.members[0].account_id, "acc1");
assert_eq!(got.members[0].apps, vec!["Music".to_string()]);
assert_eq!(got.published_apps, vec!["Music".to_string()]);
let (base, rx) = one_shot(201, r#"{"email":"a@b.c","status":"pending"}"#);
invite(&base, "a@b.c", &["Music".to_string()]).expect("invite");
let (start, _h, body) = rx.recv().unwrap();
assert!(start.starts_with("POST /people/invite"), "{start}");
assert!(body.contains("\"email\":\"a@b.c\""), "{body}");
assert!(body.contains("Music"), "{body}");
let (base, _rx) = one_shot(429, r#"{"error":"too many requests, try again shortly"}"#);
let err = invite(&base, "a@b.c", &[]).unwrap_err();
assert_eq!(err.to_string(), "too many requests, try again shortly",
"a rate limit must reach the person as the service worded it");
let err = people("http://127.0.0.1:1").unwrap_err();
assert!(matches!(err, PeopleError::Unreachable));
let (base, rx) = one_shot(200, "{}");
grant(&base, "acc1", "Music", false).expect("grant");
let (start, _h, body) = rx.recv().unwrap();
assert!(start.starts_with("POST /people/grant"), "{start}");
assert!(body.contains("\"accountId\":\"acc1\"") && body.contains("\"granted\":false"), "{body}");
let (base, rx) = one_shot(200, "{}");
revoke(&base, "acc1").expect("revoke");
let (start, _h, body) = rx.recv().unwrap();
assert!(start.starts_with("POST /people/revoke"), "{start}");
assert!(body.contains("\"accountId\":\"acc1\""), "{body}");
}
}