1use std::path::PathBuf;
61use std::process::{Child, Command, Stdio};
62use std::time::{Duration, Instant};
63
64use serde::Deserialize;
65
66pub const ENGINE_PROTOCOL: u32 = 1;
69
70pub const DEFAULT_MGMT_BASE: &str = "http://127.0.0.1:8765";
72
73const CALL_TIMEOUT: Duration = Duration::from_millis(1500);
74
75fn client() -> reqwest::blocking::Client {
76 reqwest::blocking::Client::new()
77}
78
79#[derive(Debug, Clone, Deserialize, Default)]
86#[serde(rename_all = "camelCase")]
87pub struct BillingInfo {
88 #[serde(default)]
89 pub entitled: bool,
90 #[serde(default)]
92 pub status: String,
93 #[serde(default)]
94 pub trial_ends_at: Option<i64>,
95}
96
97#[derive(Debug, Clone, Deserialize, Default)]
99#[serde(rename_all = "camelCase")]
100pub struct EngineInfo {
101 #[serde(default)]
102 pub engine_version: String,
103 #[serde(default)]
104 pub protocol: u32,
105 #[serde(default)]
106 pub pid: u32,
107 #[serde(default)]
108 pub mode: String,
109 #[serde(default)]
110 pub connected: bool,
111 #[serde(default)]
112 pub host: Option<String>,
113 #[serde(default)]
114 pub name: Option<String>,
115 #[serde(default)]
116 pub first_party_app: Option<String>,
117 #[serde(default)]
118 pub registrants: u32,
119 #[serde(default)]
120 pub billing: Option<BillingInfo>,
121}
122
123impl EngineInfo {
124 pub fn needs_renewal(&self) -> bool {
127 matches!(
128 self.billing.as_ref().map(|b| b.status.as_str()),
129 Some("hold") | Some("past_due")
130 )
131 }
132}
133
134pub fn discover(mgmt_base: &str) -> Option<EngineInfo> {
137 client()
138 .get(format!("{mgmt_base}/engine/info"))
139 .timeout(CALL_TIMEOUT)
140 .send()
141 .ok()?
142 .json::<EngineInfo>()
143 .ok()
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum StartAction {
149 Attach,
151 Takeover,
153}
154
155pub fn decide_start_action(info: Option<&EngineInfo>, protocol: u32) -> StartAction {
158 match info {
159 Some(i) if i.protocol == protocol => StartAction::Attach,
160 _ => StartAction::Takeover,
161 }
162}
163
164pub fn register(mgmt_base: &str, app_id: &str, pid: u32) -> reqwest::Result<()> {
176 let res = client()
177 .post(format!("{mgmt_base}/engine/register"))
178 .json(&serde_json::json!({ "appId": app_id, "pid": pid }))
179 .timeout(CALL_TIMEOUT)
180 .send()?;
181 if let Ok(body) = res.json::<RegisterReply>() {
182 if let Some(cap) = body.capability {
183 store_capability(cap);
184 }
185 }
186 Ok(())
187}
188
189#[derive(Debug, Deserialize)]
190struct RegisterReply {
191 capability: Option<String>,
192}
193
194static CAPABILITY: std::sync::RwLock<Option<String>> = std::sync::RwLock::new(None);
198
199fn store_capability(cap: String) {
200 if let Ok(mut slot) = CAPABILITY.write() {
201 *slot = Some(cap);
202 }
203}
204
205fn capability() -> Option<String> {
206 CAPABILITY.read().ok().and_then(|slot| slot.clone())
207}
208
209pub fn heartbeat(mgmt_base: &str, app_id: &str, pid: u32) {
212 let _ = client()
213 .post(format!("{mgmt_base}/engine/heartbeat"))
214 .json(&serde_json::json!({ "appId": app_id, "pid": pid }))
215 .timeout(CALL_TIMEOUT)
216 .send();
217}
218
219pub fn deregister(mgmt_base: &str, app_id: &str) {
222 let _ = client()
223 .post(format!("{mgmt_base}/engine/deregister"))
224 .json(&serde_json::json!({ "appId": app_id }))
225 .timeout(CALL_TIMEOUT)
226 .send();
227}
228
229#[derive(Debug, Clone, Deserialize, Default)]
235#[serde(rename_all = "camelCase")]
236pub struct PublishResult {
237 #[serde(default)]
238 pub status: String,
239 #[serde(default)]
240 pub host: Option<String>,
241 #[serde(default)]
242 pub url: Option<String>,
243}
244
245pub fn publish(
249 mgmt_base: &str,
250 name: &str,
251 label: &str,
252 local_port: u16,
253 app_id: Option<&str>,
254) -> reqwest::Result<PublishResult> {
255 let mut body = serde_json::json!({ "name": name, "label": label, "localPort": local_port });
256 if let Some(id) = app_id {
257 body["appId"] = serde_json::Value::String(id.to_string());
258 }
259 client()
260 .post(format!("{mgmt_base}/publish"))
261 .json(&body)
262 .timeout(CALL_TIMEOUT)
263 .send()?
264 .json::<PublishResult>()
265}
266
267pub fn publish_status(mgmt_base: &str, name: &str) -> reqwest::Result<PublishResult> {
269 client()
270 .get(format!("{mgmt_base}/publish/{name}"))
271 .timeout(CALL_TIMEOUT)
272 .send()?
273 .json::<PublishResult>()
274}
275
276pub fn unpublish(mgmt_base: &str, name: &str) {
278 let _ = client()
279 .delete(format!("{mgmt_base}/publish/{name}"))
280 .timeout(CALL_TIMEOUT)
281 .send();
282}
283
284pub fn status(mgmt_base: &str) -> Option<serde_json::Value> {
286 client()
287 .get(format!("{mgmt_base}/status"))
288 .timeout(CALL_TIMEOUT)
289 .send()
290 .ok()?
291 .json::<serde_json::Value>()
292 .ok()
293}
294
295#[derive(Debug, Clone, Deserialize)]
301#[serde(rename_all = "camelCase")]
302pub struct Person {
303 pub email: String,
304 pub account_id: String,
305 pub role: String,
307 pub status: String,
309 #[serde(default)]
311 pub apps: Vec<String>,
312}
313
314#[derive(Debug, Clone, Deserialize, Default)]
316#[serde(rename_all = "camelCase")]
317pub struct People {
318 #[serde(default)]
320 pub name: String,
321 #[serde(default)]
322 pub members: Vec<Person>,
323 #[serde(default)]
325 pub published_apps: Vec<String>,
326}
327
328#[derive(Debug, thiserror::Error)]
332pub enum PeopleError {
333 #[error("not attached to an engine yet")]
335 NotAttached,
336 #[error("{0}")]
338 Refused(String),
339 #[error("could not reach the sharing service")]
341 Unreachable,
342}
343
344fn people_call(
345 mgmt_base: &str,
346 method: reqwest::Method,
347 path: &str,
348 body: Option<serde_json::Value>,
349) -> Result<serde_json::Value, PeopleError> {
350 let cap = capability().ok_or(PeopleError::NotAttached)?;
351 let mut req = client()
352 .request(method, format!("{mgmt_base}{path}"))
353 .header("x-engine-capability", cap)
354 .timeout(CALL_TIMEOUT);
355 if let Some(b) = body {
356 req = req.json(&b);
357 }
358 let res = req.send().map_err(|_| PeopleError::Unreachable)?;
359 let status = res.status();
360 let parsed: serde_json::Value = res.json().unwrap_or(serde_json::Value::Null);
361 if status.is_success() {
362 return Ok(parsed);
363 }
364 Err(PeopleError::Refused(
367 parsed
368 .get("error")
369 .and_then(|e| e.as_str())
370 .unwrap_or("that did not work")
371 .to_string(),
372 ))
373}
374
375pub fn people(mgmt_base: &str) -> Result<People, PeopleError> {
377 let raw = people_call(mgmt_base, reqwest::Method::GET, "/people", None)?;
378 serde_json::from_value(raw).map_err(|_| PeopleError::Refused("unexpected reply".into()))
379}
380
381pub fn invite(mgmt_base: &str, email: &str, apps: &[String]) -> Result<(), PeopleError> {
387 people_call(
388 mgmt_base,
389 reqwest::Method::POST,
390 "/people/invite",
391 Some(serde_json::json!({ "email": email, "apps": apps })),
392 )
393 .map(|_| ())
394}
395
396pub fn grant(
398 mgmt_base: &str,
399 account_id: &str,
400 app: &str,
401 granted: bool,
402) -> Result<(), PeopleError> {
403 people_call(
404 mgmt_base,
405 reqwest::Method::POST,
406 "/people/grant",
407 Some(serde_json::json!({ "accountId": account_id, "app": app, "granted": granted })),
408 )
409 .map(|_| ())
410}
411
412pub fn revoke(mgmt_base: &str, account_id: &str) -> Result<(), PeopleError> {
414 people_call(
415 mgmt_base,
416 reqwest::Method::POST,
417 "/people/revoke",
418 Some(serde_json::json!({ "accountId": account_id })),
419 )
420 .map(|_| ())
421}
422
423#[derive(Debug, Clone)]
430pub struct EngineConfig {
431 pub node_bin: PathBuf,
433 pub agent_path: PathBuf,
435 pub mode: String,
437 pub device_token: String,
439 pub relay_addr: String,
441 pub control_plane: String,
443 pub local_port: u16,
445 pub mgmt_secret: String,
447 pub frp_token: Option<String>,
449 pub frpc_bin: Option<PathBuf>,
451 pub cert_mode: Option<String>,
453 pub first_party_app: Option<String>,
455 pub engine_version: Option<String>,
457 pub work_dir: Option<PathBuf>,
459 pub mgmt_port: Option<u16>,
461}
462
463impl EngineConfig {
464 pub fn portal(
466 node_bin: PathBuf,
467 agent_path: PathBuf,
468 device_token: String,
469 relay_addr: String,
470 control_plane: String,
471 mgmt_secret: String,
472 ) -> Self {
473 EngineConfig {
474 node_bin,
475 agent_path,
476 mode: "portal".into(),
477 device_token,
478 relay_addr,
479 control_plane,
480 local_port: 8443,
481 mgmt_secret,
482 frp_token: None,
483 frpc_bin: None,
484 cert_mode: None,
485 first_party_app: None,
486 engine_version: None,
487 work_dir: None,
488 mgmt_port: None,
489 }
490 }
491
492 pub fn to_args(&self) -> Vec<String> {
503 let mut a: Vec<String> = vec![
504 "--mode".into(),
505 self.mode.clone(),
506 "--relay-addr".into(),
507 self.relay_addr.clone(),
508 "--control-plane".into(),
509 self.control_plane.clone(),
510 "--local-port".into(),
511 self.local_port.to_string(),
512 ];
513 if let Some(fb) = &self.frpc_bin {
514 a.push("--frpc-bin".into());
515 a.push(fb.display().to_string());
516 }
517 if let Some(cm) = &self.cert_mode {
518 a.push("--cert-mode".into());
519 a.push(cm.clone());
520 }
521 if let Some(fp) = &self.first_party_app {
522 a.push("--first-party-app".into());
523 a.push(fp.clone());
524 }
525 if let Some(ev) = &self.engine_version {
526 a.push("--engine-version".into());
527 a.push(ev.clone());
528 }
529 if let Some(wd) = &self.work_dir {
530 a.push("--work-dir".into());
531 a.push(wd.display().to_string());
532 }
533 if let Some(mp) = self.mgmt_port {
534 a.push("--mgmt-port".into());
535 a.push(mp.to_string());
536 }
537 a
538 }
539
540 pub fn to_envs(&self) -> Vec<(String, String)> {
544 let mut e = Vec::new();
545 if !self.device_token.is_empty() {
546 e.push(("AGENT_DEVICE_TOKEN".into(), self.device_token.clone()));
547 }
548 if !self.mgmt_secret.is_empty() {
549 e.push(("AGENT_MGMT_SECRET".into(), self.mgmt_secret.clone()));
550 }
551 if let Some(ft) = self.frp_token.as_ref().filter(|s| !s.is_empty()) {
552 e.push(("AGENT_FRP_TOKEN".into(), ft.clone()));
553 }
554 e
555 }
556
557 pub fn command(&self) -> Command {
561 let mut c = Command::new(&self.node_bin);
562 c.arg(&self.agent_path);
563 c.args(self.to_args());
564 c.envs(self.to_envs());
565 c
566 }
567
568 pub fn spawn(&self) -> std::io::Result<Child> {
570 let mut c = self.command();
571 c.stdout(Stdio::null()).stderr(Stdio::null());
572 c.spawn()
573 }
574
575 pub fn mgmt_base(&self) -> String {
577 format!("http://127.0.0.1:{}", self.mgmt_port.unwrap_or(8765))
578 }
579}
580
581pub enum StartOutcome {
583 Attached(EngineInfo),
585 Spawned(Child),
587}
588
589pub fn spawn_or_attach(
593 cfg: &EngineConfig,
594 app_id: &str,
595 pid: u32,
596) -> std::io::Result<StartOutcome> {
597 let base = cfg.mgmt_base();
598 if let Some(info) = discover(&base) {
599 if decide_start_action(Some(&info), ENGINE_PROTOCOL) == StartAction::Attach {
600 let _ = register(&base, app_id, pid);
601 return Ok(StartOutcome::Attached(info));
602 }
603 }
604 Ok(StartOutcome::Spawned(cfg.spawn()?))
605}
606
607pub fn wait_engine(mgmt_base: &str, timeout: Duration) -> bool {
611 let deadline = Instant::now() + timeout;
612 loop {
613 if discover(mgmt_base).is_some() {
614 return true;
615 }
616 if Instant::now() >= deadline {
617 return false;
618 }
619 std::thread::sleep(Duration::from_millis(200));
620 }
621}
622
623#[derive(Debug, Clone, Deserialize)]
630#[serde(rename_all = "camelCase")]
631pub struct DeviceCode {
632 pub code: String,
633 pub verify_url: String,
634}
635
636#[derive(Debug, Clone, Deserialize, Default)]
638#[serde(rename_all = "camelCase")]
639pub struct Exchange {
640 #[serde(default)]
641 pub status: String, #[serde(default)]
643 pub device_token: Option<String>,
644 #[serde(default)]
645 pub host: Option<String>,
646}
647
648#[derive(Debug)]
650pub enum ConnectError {
651 Http(reqwest::Error),
652 Io(std::io::Error),
653 CodeExpired,
655 Timeout,
657 NoCredential,
659 EngineUnreachable,
661}
662
663impl std::fmt::Display for ConnectError {
664 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
665 match self {
666 ConnectError::Http(e) => write!(f, "network error: {e}"),
667 ConnectError::Io(e) => write!(f, "spawn error: {e}"),
668 ConnectError::CodeExpired => write!(f, "the approval code expired — please try again"),
669 ConnectError::Timeout => write!(f, "timed out waiting for approval in the browser"),
670 ConnectError::NoCredential => write!(f, "approval returned no credential"),
671 ConnectError::EngineUnreachable => write!(f, "the engine did not come up in time"),
672 }
673 }
674}
675impl std::error::Error for ConnectError {}
676impl From<reqwest::Error> for ConnectError {
677 fn from(e: reqwest::Error) -> Self {
678 ConnectError::Http(e)
679 }
680}
681impl From<std::io::Error> for ConnectError {
682 fn from(e: std::io::Error) -> Self {
683 ConnectError::Io(e)
684 }
685}
686
687pub fn request_device_code(control_plane: &str) -> reqwest::Result<DeviceCode> {
689 client()
690 .post(format!("{control_plane}/device/code"))
691 .timeout(Duration::from_secs(10))
692 .send()?
693 .json::<DeviceCode>()
694}
695
696pub fn poll_exchange(
699 control_plane: &str,
700 code: &str,
701 timeout: Duration,
702 interval: Duration,
703) -> Result<Exchange, ConnectError> {
704 let deadline = Instant::now() + timeout;
705 loop {
706 let ex: Exchange = client()
707 .get(format!("{control_plane}/device/exchange?code={code}"))
708 .timeout(Duration::from_secs(10))
709 .send()?
710 .json()?;
711 match ex.status.as_str() {
712 "approved" => return Ok(ex),
713 "unknown" => return Err(ConnectError::CodeExpired),
714 _ => {}
715 }
716 if Instant::now() >= deadline {
717 return Err(ConnectError::Timeout);
718 }
719 std::thread::sleep(interval);
720 }
721}
722
723pub struct ConnectRequest<'a> {
725 pub control_plane: &'a str,
727 pub app_id: &'a str,
729 pub publish_name: &'a str,
731 pub publish_label: &'a str,
733 pub local_port: u16,
735 pub poll_timeout: Duration,
737}
738
739pub struct Connected {
741 pub device_token: String,
742 pub host: String,
743 pub publish: PublishResult,
744 pub attached: bool,
746}
747
748pub fn connect<O, P, B>(
757 req: &ConnectRequest,
758 pid: u32,
759 open_url: O,
760 persist: P,
761 build_config: B,
762) -> Result<Connected, ConnectError>
763where
764 O: FnOnce(&str),
765 P: FnOnce(&str, &str),
766 B: FnOnce(&str) -> EngineConfig,
767{
768 let dc = request_device_code(req.control_plane)?;
769 open_url(&dc.verify_url);
770 let ex = poll_exchange(req.control_plane, &dc.code, req.poll_timeout, Duration::from_secs(2))?;
771 let token = ex.device_token.ok_or(ConnectError::NoCredential)?;
772 let host = ex.host.unwrap_or_default();
773 persist(&token, &host);
774
775 let cfg = build_config(&token);
776 let base = cfg.mgmt_base();
777 let outcome = spawn_or_attach(&cfg, req.app_id, pid)?;
778 let attached = matches!(outcome, StartOutcome::Attached(_));
779
780 if !wait_engine(&base, Duration::from_secs(30)) {
781 return Err(ConnectError::EngineUnreachable);
782 }
783 let publish = publish(&base, req.publish_name, req.publish_label, req.local_port, Some(req.app_id))?;
784 Ok(Connected { device_token: token, host, publish, attached })
785}
786
787#[cfg(test)]
788mod tests {
789 use super::*;
790
791 fn info(protocol: u32) -> EngineInfo {
792 EngineInfo { protocol, ..Default::default() }
793 }
794
795 #[test]
796 fn attach_only_on_matching_protocol() {
797 assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL)), ENGINE_PROTOCOL), StartAction::Attach);
798 assert_eq!(decide_start_action(Some(&info(ENGINE_PROTOCOL + 1)), ENGINE_PROTOCOL), StartAction::Takeover);
799 assert_eq!(decide_start_action(Some(&info(0)), ENGINE_PROTOCOL), StartAction::Takeover);
800 }
801
802 #[test]
803 fn takeover_when_no_engine_answers() {
804 assert_eq!(decide_start_action(None, ENGINE_PROTOCOL), StartAction::Takeover);
805 }
806
807 #[test]
808 fn engine_info_parses_camelcase() {
809 let j = r#"{"engineVersion":"0.4.0","protocol":1,"pid":42,"mode":"portal",
810 "connected":true,"host":"alice.meradomo.com","name":"alice",
811 "firstPartyApp":"com.example.app","registrants":2}"#;
812 let i: EngineInfo = serde_json::from_str(j).unwrap();
813 assert_eq!(i.engine_version, "0.4.0");
814 assert_eq!(i.protocol, 1);
815 assert_eq!(i.pid, 42);
816 assert_eq!(i.connected, true);
817 assert_eq!(i.name.as_deref(), Some("alice"));
818 assert_eq!(i.first_party_app.as_deref(), Some("com.example.app"));
819 assert_eq!(i.registrants, 2);
820 }
821
822 #[test]
823 fn bare_config_emits_exactly_the_portal_flags() {
824 let cfg = EngineConfig::portal(
825 "node".into(),
826 "agent.mjs".into(),
827 "tok".into(),
828 "relay:7000".into(),
829 "http://cp:9002".into(),
830 "secret".into(),
831 );
832 let args = cfg.to_args();
833 assert_eq!(
834 args,
835 vec![
836 "--mode", "portal",
837 "--relay-addr", "relay:7000",
838 "--control-plane", "http://cp:9002",
839 "--local-port", "8443",
840 ]
841 );
842 }
843
844 #[test]
845 fn secrets_travel_by_env_never_argv() {
846 let mut cfg = EngineConfig::portal(
847 "node".into(),
848 "agent.mjs".into(),
849 "device-tok".into(),
850 "relay:7000".into(),
851 "http://cp:9002".into(),
852 "owner-secret".into(),
853 );
854 cfg.frp_token = Some("relay-tok".into());
855
856 let joined = cfg.to_args().join(" ");
857 for secret in ["device-tok", "owner-secret", "relay-tok"] {
858 assert!(!joined.contains(secret), "argv leaked {secret}: {joined}");
859 }
860 let envs = cfg.to_envs();
861 assert!(envs.contains(&("AGENT_DEVICE_TOKEN".into(), "device-tok".into())));
862 assert!(envs.contains(&("AGENT_MGMT_SECRET".into(), "owner-secret".into())));
863 assert!(envs.contains(&("AGENT_FRP_TOKEN".into(), "relay-tok".into())));
864
865 cfg.mgmt_secret = String::new();
868 cfg.frp_token = None;
869 let envs = cfg.to_envs();
870 assert_eq!(envs.len(), 1, "only the device token remains: {envs:?}");
871 }
872
873 #[test]
874 fn optional_flags_appear_only_when_set() {
875 let mut cfg = EngineConfig::portal(
876 "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
877 );
878 cfg.frpc_bin = Some("/side/frpc".into());
879 cfg.cert_mode = Some("acme".into());
880 cfg.first_party_app = Some("com.example.app".into());
881 cfg.engine_version = Some("0.4.0".into());
882 let args = cfg.to_args();
883 assert!(args.windows(2).any(|w| w == ["--frpc-bin", "/side/frpc"]));
884 assert!(args.windows(2).any(|w| w == ["--cert-mode", "acme"]));
885 assert!(args.windows(2).any(|w| w == ["--first-party-app", "com.example.app"]));
886 assert!(args.windows(2).any(|w| w == ["--engine-version", "0.4.0"]));
887 }
888
889 #[test]
890 fn empty_frp_token_is_omitted() {
891 let mut cfg = EngineConfig::portal(
892 "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
893 );
894 cfg.frp_token = Some(String::new());
895 assert!(!cfg.to_envs().iter().any(|(k, _)| k == "AGENT_FRP_TOKEN"));
896 }
897
898 #[test]
899 fn mgmt_base_reflects_port() {
900 let mut cfg = EngineConfig::portal(
901 "node".into(), "a.mjs".into(), "t".into(), "r".into(), "c".into(), "s".into(),
902 );
903 assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8765");
904 cfg.mgmt_port = Some(8790);
905 assert_eq!(cfg.mgmt_base(), "http://127.0.0.1:8790");
906 }
907
908 #[test]
909 fn device_code_parses() {
910 let dc: DeviceCode = serde_json::from_str(
911 r#"{"code":"abc123","verifyUrl":"https://account.meradomo.com/device/approve?code=abc123"}"#,
912 )
913 .unwrap();
914 assert_eq!(dc.code, "abc123");
915 assert!(dc.verify_url.contains("device/approve"));
916 }
917
918 #[test]
919 fn exchange_pending_then_approved() {
920 let pending: Exchange = serde_json::from_str(r#"{"status":"pending"}"#).unwrap();
921 assert_eq!(pending.status, "pending");
922 assert!(pending.device_token.is_none());
923
924 let approved: Exchange = serde_json::from_str(
925 r#"{"status":"approved","deviceToken":"tok-xyz","host":"alice.meradomo.com"}"#,
926 )
927 .unwrap();
928 assert_eq!(approved.status, "approved");
929 assert_eq!(approved.device_token.as_deref(), Some("tok-xyz"));
930 assert_eq!(approved.host.as_deref(), Some("alice.meradomo.com"));
931 }
932
933 #[test]
934 fn connect_error_messages_are_human() {
935 assert!(ConnectError::Timeout.to_string().contains("browser"));
936 assert!(ConnectError::CodeExpired.to_string().contains("expired"));
937 assert!(ConnectError::EngineUnreachable.to_string().contains("engine"));
938 }
939
940 #[test]
941 fn needs_renewal_only_on_lapse() {
942 let mk = |s: &str| EngineInfo {
943 billing: Some(BillingInfo { status: s.into(), ..Default::default() }),
944 ..Default::default()
945 };
946 assert!(mk("hold").needs_renewal());
947 assert!(mk("past_due").needs_renewal());
948 assert!(!mk("active").needs_renewal());
949 assert!(!mk("trialing").needs_renewal());
950 assert!(!mk("comp").needs_renewal());
951 assert!(!EngineInfo::default().needs_renewal());
953 }
954
955 #[test]
956 fn engine_info_parses_billing() {
957 let j = r#"{"protocol":1,"billing":{"entitled":false,"status":"hold","trialEndsAt":123}}"#;
958 let i: EngineInfo = serde_json::from_str(j).unwrap();
959 let b = i.billing.as_ref().unwrap();
960 assert_eq!(b.entitled, false);
961 assert_eq!(b.status, "hold");
962 assert_eq!(b.trial_ends_at, Some(123));
963 assert!(i.needs_renewal());
964 }
965
966 use std::io::{BufRead, BufReader, Read, Write};
975 use std::net::TcpListener;
976 use std::sync::mpsc;
977
978 fn one_shot(status: u16, reply: &str) -> (String, mpsc::Receiver<(String, String, String)>) {
981 let listener = TcpListener::bind("127.0.0.1:0").expect("bind");
982 let base = format!("http://{}", listener.local_addr().unwrap());
983 let (tx, rx) = mpsc::channel();
984 let reply = reply.to_string();
985 std::thread::spawn(move || {
986 let (mut sock, _) = listener.accept().expect("accept");
987 let mut reader = BufReader::new(sock.try_clone().unwrap());
988 let mut start = String::new();
989 reader.read_line(&mut start).ok();
990 let mut headers = String::new();
991 let mut len = 0usize;
992 loop {
993 let mut line = String::new();
994 if reader.read_line(&mut line).unwrap_or(0) == 0 { break; }
995 if line.trim().is_empty() { break; }
996 if let Some(v) = line.to_lowercase().strip_prefix("content-length:") {
997 len = v.trim().parse().unwrap_or(0);
998 }
999 headers.push_str(&line);
1000 }
1001 let mut body = vec![0u8; len];
1002 if len > 0 { reader.read_exact(&mut body).ok(); }
1003 tx.send((
1004 start.trim().to_string(),
1005 headers,
1006 String::from_utf8_lossy(&body).to_string(),
1007 )).ok();
1008 let out = format!(
1009 "HTTP/1.1 {status} X\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{reply}",
1010 reply.len()
1011 );
1012 sock.write_all(out.as_bytes()).ok();
1013 sock.flush().ok();
1014 });
1015 (base, rx)
1016 }
1017
1018 #[test]
1021 fn people_surface() {
1022 if let Ok(mut slot) = CAPABILITY.write() { *slot = None; }
1024 let err = people("http://127.0.0.1:1").unwrap_err();
1025 assert!(matches!(err, PeopleError::NotAttached),
1026 "an app that never registered must not be able to manage people");
1027
1028 let (base, rx) = one_shot(200, r#"{"ok":true,"capability":"cap-xyz-123456789012345"}"#);
1030 register(&base, "com.example.app", 42).expect("register");
1031 let (start, _h, body) = rx.recv().expect("no request arrived");
1032 assert!(start.starts_with("POST /engine/register"), "{start}");
1033 assert!(body.contains("com.example.app"));
1034 assert_eq!(capability().as_deref(), Some("cap-xyz-123456789012345"));
1035
1036 let (base, rx) = one_shot(
1038 200,
1039 r#"{"name":"example","members":[{"email":"a@b.c","accountId":"acc1","role":"member","status":"active","apps":["Music"]}],"publishedApps":["Music"]}"#,
1040 );
1041 let got = people(&base).expect("people");
1042 let (start, headers, _b) = rx.recv().unwrap();
1043 assert!(start.starts_with("GET /people"), "{start}");
1044 assert!(headers.to_lowercase().contains("x-engine-capability: cap-xyz-123456789012345"),
1045 "the capability header was not sent: {headers}");
1046 assert_eq!(got.name, "example");
1047 assert_eq!(got.members.len(), 1);
1048 assert_eq!(got.members[0].account_id, "acc1");
1049 assert_eq!(got.members[0].apps, vec!["Music".to_string()]);
1050 assert_eq!(got.published_apps, vec!["Music".to_string()]);
1051
1052 let (base, rx) = one_shot(201, r#"{"email":"a@b.c","status":"pending"}"#);
1054 invite(&base, "a@b.c", &["Music".to_string()]).expect("invite");
1055 let (start, _h, body) = rx.recv().unwrap();
1056 assert!(start.starts_with("POST /people/invite"), "{start}");
1057 assert!(body.contains("\"email\":\"a@b.c\""), "{body}");
1058 assert!(body.contains("Music"), "{body}");
1059
1060 let (base, _rx) = one_shot(429, r#"{"error":"too many requests, try again shortly"}"#);
1062 let err = invite(&base, "a@b.c", &[]).unwrap_err();
1063 assert_eq!(err.to_string(), "too many requests, try again shortly",
1064 "a rate limit must reach the person as the service worded it");
1065
1066 let err = people("http://127.0.0.1:1").unwrap_err();
1068 assert!(matches!(err, PeopleError::Unreachable));
1069
1070 let (base, rx) = one_shot(200, "{}");
1072 grant(&base, "acc1", "Music", false).expect("grant");
1073 let (start, _h, body) = rx.recv().unwrap();
1074 assert!(start.starts_with("POST /people/grant"), "{start}");
1075 assert!(body.contains("\"accountId\":\"acc1\"") && body.contains("\"granted\":false"), "{body}");
1076
1077 let (base, rx) = one_shot(200, "{}");
1078 revoke(&base, "acc1").expect("revoke");
1079 let (start, _h, body) = rx.recv().unwrap();
1080 assert!(start.starts_with("POST /people/revoke"), "{start}");
1081 assert!(body.contains("\"accountId\":\"acc1\""), "{body}");
1082 }
1083}