1mod apply;
4mod options;
5mod probe;
6mod reboot;
7mod status;
8mod token;
9
10pub use apply::{
11 remote_ensure_ssh_key, remote_install_payload, remote_run_cli, remote_setup_run,
12 remote_upload_cli, RemoteRunOutcome,
13};
14pub use options::{
15 remote_doctor_report_banner, remote_progress_message, remote_run_banner_detail, session_from,
16 RemoteOptions, RemoteOptionsInput, RemoteRunFlags, RemoteRunRequest, DEFAULT_GITHUB_REPO,
17 DEFAULT_INSTALL_DIR, DEFAULT_REMOTE_AGENT_DIR,
18};
19pub use probe::{
20 classify_ssh_failure, normalize_cli_version, probe_remote_cli, remote_box_snapshot,
21 remote_cli_version_is_current, remote_probe_arch, RemoteBoxCliStatus, RemoteBoxSnapshot,
22 RemoteCliProbe,
23};
24pub use reboot::{remote_reboot, remote_reboot_with_sudo_password, wants_reboot_now};
25pub use status::{parse_remote_json, remote_doctor, remote_setup_status};
26pub use token::api_token_config_path;
27pub use token::{
28 finish_save_api_token, offer_save_api_token, parse_api_token_drop, pull_remote_api_token,
29 usable_api_token_hex, write_api_token_file, MIN_API_TOKEN_HEX_LEN,
30};
31
32#[cfg(test)]
33mod tests {
34 use super::super::arch::BoxArch;
35 use super::super::bins::LocalBins;
36 use super::super::ecosystem::EcosystemInstallChoice;
37 use super::super::ssh::SshSession;
38 use super::options::{build_remote_command_at, remote_progress, shell_quote};
39 use super::probe::ssh_command_detail;
40 use super::reboot::finish_remote_reboot;
41 use super::status::parse_setup_status_text;
42 use super::token::offer_save_api_token_with;
43 use super::*;
44 use crate::remote::process::{CommandOutput, ScriptedRunner};
45 use crate::LONG_VERSION;
46 use std::fs;
47 use tempfile::TempDir;
48
49 use crate::remote::ENV_LOCK as CONFIG_ENV_LOCK;
51
52 struct EnvVarGuard {
54 key: &'static str,
55 prev: Option<std::ffi::OsString>,
56 }
57
58 impl EnvVarGuard {
59 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
60 let prev = std::env::var_os(key);
61 unsafe {
63 std::env::set_var(key, value);
64 }
65 Self { key, prev }
66 }
67
68 fn remove(key: &'static str) -> Self {
69 let prev = std::env::var_os(key);
70 unsafe {
71 std::env::remove_var(key);
72 }
73 Self { key, prev }
74 }
75 }
76
77 impl Drop for EnvVarGuard {
78 fn drop(&mut self) {
79 unsafe {
80 match &self.prev {
81 Some(v) => std::env::set_var(self.key, v),
82 None => std::env::remove_var(self.key),
83 }
84 }
85 }
86 }
87
88 fn bin_dir_with_stubs() -> TempDir {
89 let tmp = TempDir::new().unwrap();
90 for name in [
91 "horto-os-ui",
92 "horto-os-ui-tui",
93 "horto-os-ui-status-api",
94 "horto-os-ui-mcp",
95 ] {
96 fs::write(tmp.path().join(name), b"#!/bin/true\n").unwrap();
97 }
98 tmp
99 }
100
101 #[test]
102 fn shell_quote_safe_and_unsafe() {
103 assert_eq!(shell_quote(""), "''");
104 assert_eq!(shell_quote("setup"), "setup");
105 assert_eq!(shell_quote("a b"), "'a b'");
106 assert_eq!(shell_quote("a'b"), "'a'\\''b'");
107 }
108
109 #[test]
110 fn remote_progress_message_labels_host_and_detail() {
111 assert_eq!(
112 remote_progress_message("horto", "probe arch (SSH; may ask password)"),
113 "[horto remote] PC → box 'horto': probe arch (SSH; may ask password)"
114 );
115 }
116
117 #[test]
118 fn parse_setup_status_text_reads_cli_lines() {
119 let raw = "\
120Setup kind: full
121 [ done] s1 - Install base packages (v1)
122 [pending] s5 - Apply staged configs to /etc (v2) [destructive] [reboot]
123";
124 let report = parse_setup_status_text(raw).unwrap();
125 assert_eq!(report.kind, "full");
126 assert_eq!(report.steps.len(), 2);
127 assert_eq!(report.steps[0].id, "s1");
128 assert_eq!(report.steps[0].status, "done");
129 assert_eq!(report.steps[1].id, "s5");
130 assert!(report.steps[1].destructive);
131 assert!(report.steps[1].needs_reboot_after);
132 }
133
134 #[test]
135 fn parse_setup_status_text_skips_noise_and_allows_title_without_version() {
136 let raw = "\
137noise before
138Setup kind: minimal
139[broken
140[x] no dash here
141 [ done] s2 - Title only
142";
143 let report = parse_setup_status_text(raw).unwrap();
144 assert_eq!(report.kind, "minimal");
145 assert_eq!(report.steps.len(), 1);
146 assert_eq!(report.steps[0].id, "s2");
147 assert_eq!(report.steps[0].title, "Title only");
148 assert_eq!(report.steps[0].step_version, 0);
149 }
150
151 #[test]
152 fn parse_setup_status_text_errors_without_steps() {
153 let err = parse_setup_status_text("Setup kind: full\njust noise\n").unwrap_err();
154 assert!(err.to_string().contains("no step lines"));
155 }
156
157 #[test]
158 fn parse_remote_json_skips_leading_noise() {
159 let raw = "ssh warn\n{\"is_root\":false,\"has_sudo\":true,\"docker_present\":true,\"active_setup_dir\":true,\"full_env\":true,\"minimal_env\":false,\"docker_dir\":true,\"backup_dir\":true,\"notes\":[]}\n";
160 let doc: crate::ops::doctor::DoctorReport = parse_remote_json(raw).unwrap();
161 assert!(doc.has_sudo);
162 assert!(doc.docker_present);
163 }
164
165 #[test]
166 fn remote_run_banner_detail_marks_sudo() {
167 assert!(remote_run_banner_detail("horto-os-ui doctor", false).contains("SSH;"));
168 assert!(!remote_run_banner_detail("horto-os-ui doctor", false).contains("sudo"));
169 assert!(remote_run_banner_detail("sudo horto-os-ui setup", true).contains("SSH + sudo"));
170 }
171
172 #[test]
173 fn remote_progress_and_doctor_banner_emit() {
174 remote_progress("box", "unit-test detail");
175 remote_doctor_report_banner();
176 }
177
178 fn push_cli_probes_missing(runner: &ScriptedRunner) {
179 runner.push("ssh", ScriptedRunner::fail(1, "missing"));
181 runner.push("ssh", ScriptedRunner::fail(1, "missing"));
182 }
183
184 fn push_cli_probe_current(runner: &ScriptedRunner) {
185 runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
186 }
187
188 #[test]
189 fn normalize_and_match_cli_version() {
190 assert_eq!(
191 normalize_cli_version(&format!(" {LONG_VERSION} \n")),
192 LONG_VERSION
193 );
194 assert!(remote_cli_version_is_current(LONG_VERSION));
195 assert!(remote_cli_version_is_current(&format!(
196 "horto-os-ui {LONG_VERSION}"
197 )));
198 assert!(!remote_cli_version_is_current("0.0.0 (deadbeef)"));
199 assert!(!remote_cli_version_is_current(""));
200 }
201
202 #[test]
203 fn classify_ssh_failure_auth_vs_unreachable() {
204 assert_eq!(
205 classify_ssh_failure("exit 255: Permission denied (publickey,password)"),
206 RemoteBoxCliStatus::AuthFailed
207 );
208 assert_eq!(
209 classify_ssh_failure("exit 255: Connection refused"),
210 RemoteBoxCliStatus::Unreachable
211 );
212 assert_eq!(
213 classify_ssh_failure("exit 255: Could not resolve hostname"),
214 RemoteBoxCliStatus::Unreachable
215 );
216 }
217
218 #[test]
219 fn remote_box_cli_status_as_label_covers_all_variants() {
220 assert_eq!(RemoteBoxCliStatus::Missing.as_label(), "missing");
221 assert_eq!(RemoteBoxCliStatus::AuthFailed.as_label(), "auth failed");
222 assert_eq!(RemoteBoxCliStatus::Unreachable.as_label(), "unreachable");
223 assert_eq!(
224 RemoteBoxCliStatus::Found("0.1.0 (abc)".into()).as_label(),
225 "0.1.0 (abc)"
226 );
227 }
228
229 #[test]
230 fn ssh_command_detail_filters_non_ssh_errors() {
231 assert!(ssh_command_detail(&crate::error::HortoError::msg("nope")).is_none());
232 assert_eq!(
233 ssh_command_detail(&crate::error::HortoError::command("ssh", "exit 255: x")),
234 Some("exit 255: x")
235 );
236 assert_eq!(
237 ssh_command_detail(&crate::error::HortoError::command("scp", "denied")),
238 Some("denied")
239 );
240 assert!(ssh_command_detail(&crate::error::HortoError::command("tar", "x")).is_none());
241 }
242
243 #[test]
244 fn probe_remote_cli_reports_unreachable_on_connection_refused() {
245 let runner = ScriptedRunner::default();
246 runner.push("ssh", ScriptedRunner::fail(255, "Connection refused"));
247 let opts = RemoteOptions {
248 host: "box".into(),
249 ..RemoteOptions::default()
250 };
251 let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
252 assert_eq!(probe.status, RemoteBoxCliStatus::Unreachable);
253 }
254
255 #[test]
256 fn probe_remote_cli_reports_auth_failed() {
257 let runner = ScriptedRunner::default();
258 runner.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
259 let opts = RemoteOptions {
260 host: "box".into(),
261 ..RemoteOptions::default()
262 };
263 let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
264 assert_eq!(probe.status, RemoteBoxCliStatus::AuthFailed);
265 assert!(!probe.current);
266 }
267
268 #[test]
269 fn probe_remote_cli_reports_missing_when_binaries_absent() {
270 let runner = ScriptedRunner::default();
271 push_cli_probes_missing(&runner);
272 let opts = RemoteOptions {
273 host: "box".into(),
274 ..RemoteOptions::default()
275 };
276 let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
277 assert_eq!(probe.status, RemoteBoxCliStatus::Missing);
278 }
279
280 #[test]
281 fn remote_box_snapshot_no_scp_when_cli_current() {
282 let runner = ScriptedRunner::default();
283 push_cli_probe_current(&runner);
284 runner.push(
285 "ssh",
286 ScriptedRunner::ok(
287 r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
288 ),
289 );
290 runner.push(
291 "ssh",
292 ScriptedRunner::ok(
293 r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":["ok"]}"#,
294 ),
295 );
296
297 let snap = remote_box_snapshot(
298 &runner,
299 &RemoteOptions {
300 host: "box".into(),
301 ..RemoteOptions::default()
302 },
303 true,
304 )
305 .unwrap();
306 assert!(snap.cli_current);
307 assert_eq!(snap.setup.as_ref().unwrap().steps[0].id, "s1");
308 assert!(snap.doctor.as_ref().unwrap().has_sudo);
309 assert_eq!(
310 runner
311 .calls
312 .lock()
313 .unwrap()
314 .iter()
315 .filter(|(p, _, _, _)| p == "scp")
316 .count(),
317 0,
318 "status refresh must never SCP when CLI is current"
319 );
320 }
321
322 #[test]
323 fn remote_box_snapshot_stale_skips_status_without_scp() {
324 let runner = ScriptedRunner::default();
325 push_cli_probes_missing(&runner);
326
327 let snap = remote_box_snapshot(
328 &runner,
329 &RemoteOptions {
330 host: "box".into(),
331 ..RemoteOptions::default()
332 },
333 true,
334 )
335 .unwrap();
336 assert!(!snap.cli_current);
337 assert!(snap.setup.is_none());
338 assert!(snap.doctor.is_none());
339 assert_eq!(snap.cli_status, RemoteBoxCliStatus::Missing);
340 assert_eq!(
341 runner
342 .calls
343 .lock()
344 .unwrap()
345 .iter()
346 .filter(|(p, _, _, _)| p == "scp")
347 .count(),
348 0
349 );
350 }
351
352 #[test]
353 fn remote_box_snapshot_falls_back_to_text_status() {
354 let runner = ScriptedRunner::default();
355 push_cli_probe_current(&runner);
356 runner.push("ssh", ScriptedRunner::ok("not json\n"));
357 runner.push(
358 "ssh",
359 ScriptedRunner::ok("Setup kind: full\n [ done] s1 - Install base packages (v1)\n"),
360 );
361 runner.push(
362 "ssh",
363 ScriptedRunner::ok(
364 r#"{"is_root":false,"has_sudo":true,"docker_present":false,"active_setup_dir":false,"full_env":false,"minimal_env":true,"docker_dir":false,"backup_dir":false,"notes":[]}"#,
365 ),
366 );
367
368 let snap = remote_box_snapshot(
369 &runner,
370 &RemoteOptions {
371 host: "box".into(),
372 ..RemoteOptions::default()
373 },
374 true,
375 )
376 .unwrap();
377 assert_eq!(snap.setup.as_ref().unwrap().steps[0].id, "s1");
378 assert!(!snap.doctor.as_ref().unwrap().docker_present);
379 assert_eq!(
380 runner
381 .calls
382 .lock()
383 .unwrap()
384 .iter()
385 .filter(|(p, _, _, _)| p == "scp")
386 .count(),
387 0
388 );
389 }
390
391 #[test]
392 fn remote_upload_cli_scps_once() {
393 let stubs = bin_dir_with_stubs();
394 let runner = ScriptedRunner::default();
395 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
396 runner.push("ssh", ScriptedRunner::ok(""));
397 runner.push("scp", ScriptedRunner::ok(""));
398 runner.push("ssh", ScriptedRunner::ok(""));
399 runner.push("ssh", ScriptedRunner::fail(1, "missing"));
400 runner.push("ssh", ScriptedRunner::ok(&format!("{LONG_VERSION}\n")));
401
402 let probe = remote_upload_cli(
403 &runner,
404 &RemoteOptions {
405 host: "box".into(),
406 bin_dir: Some(stubs.path().to_path_buf()),
407 ..RemoteOptions::default()
408 },
409 )
410 .unwrap();
411 assert!(probe.current);
412 assert_eq!(
413 runner
414 .calls
415 .lock()
416 .unwrap()
417 .iter()
418 .filter(|(p, _, _, _)| p == "scp")
419 .count(),
420 1
421 );
422 }
423
424 #[test]
425 fn probe_remote_cli_skips_empty_version_then_accepts_agent() {
426 let runner = ScriptedRunner::default();
427 runner.push("ssh", ScriptedRunner::ok("\n"));
428 push_cli_probe_current(&runner);
429 let opts = RemoteOptions {
430 host: "box".into(),
431 ..RemoteOptions::default()
432 };
433 let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
434 assert!(probe.current);
435 assert!(probe.path.unwrap().contains("horto-os-ui"));
436 }
437
438 #[test]
439 fn probe_remote_cli_keeps_stale_install_when_agent_missing() {
440 let runner = ScriptedRunner::default();
441 runner.push("ssh", ScriptedRunner::ok("0.0.0 (deadbeef)\n"));
442 runner.push("ssh", ScriptedRunner::fail(1, "missing"));
443 let opts = RemoteOptions {
444 host: "box".into(),
445 ..RemoteOptions::default()
446 };
447 let probe = probe_remote_cli(&runner, &session_from(&opts).unwrap(), &opts).unwrap();
448 assert!(!probe.current);
449 assert_eq!(probe.version.as_deref(), Some("0.0.0 (deadbeef)"));
450 assert!(probe.path.is_some());
451 }
452
453 #[test]
454 fn remote_run_skips_scp_when_box_cli_already_current() {
455 let stubs = bin_dir_with_stubs();
456 let runner = ScriptedRunner::default();
457 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
458 push_cli_probe_current(&runner);
459 runner.push("ssh", ScriptedRunner::ok("ok\n"));
460
461 let log = remote_run_cli(
462 &runner,
463 &RemoteRunRequest {
464 options: RemoteOptions {
465 host: "box".into(),
466 bin_dir: Some(stubs.path().to_path_buf()),
467 ..RemoteOptions::default()
468 },
469 cli_args: vec!["doctor".into()],
470 flags: RemoteRunFlags {
471 use_sudo: false,
472 install_payload_on_success: false,
473 offer_reboot_on_success: false,
474 capture_output: false,
475 },
476 ecosystem: EcosystemInstallChoice::none(),
477 },
478 )
479 .unwrap()
480 .log;
481 assert!(log.contains("ok"));
482 assert_eq!(
483 runner
484 .calls
485 .lock()
486 .unwrap()
487 .iter()
488 .filter(|(p, _, _, _)| p == "scp")
489 .count(),
490 0
491 );
492 }
493
494 #[test]
495 fn remote_run_capture_output_merges_stderr() {
496 let stubs = bin_dir_with_stubs();
497 let runner = ScriptedRunner::default();
498 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
499 push_cli_probe_current(&runner);
500 runner.push(
501 "ssh",
502 CommandOutput {
503 status: 0,
504 stdout: "out-line\n".into(),
505 stderr: "warn-line\n".into(),
506 },
507 );
508
509 let log = remote_run_cli(
510 &runner,
511 &RemoteRunRequest {
512 options: RemoteOptions {
513 host: "box".into(),
514 bin_dir: Some(stubs.path().to_path_buf()),
515 ..RemoteOptions::default()
516 },
517 cli_args: vec!["doctor".into()],
518 flags: RemoteRunFlags {
519 use_sudo: false,
520 install_payload_on_success: false,
521 offer_reboot_on_success: false,
522 capture_output: true,
523 },
524 ecosystem: EcosystemInstallChoice::none(),
525 },
526 )
527 .unwrap()
528 .log;
529 assert!(log.contains("out-line"));
530 assert!(log.contains("warn-line"));
531 }
532
533 #[test]
534 fn remote_setup_status_and_doctor_require_current_cli() {
535 let runner = ScriptedRunner::default();
536 push_cli_probes_missing(&runner);
537 let err = remote_setup_status(
538 &runner,
539 &RemoteOptions {
540 host: "box".into(),
541 ..RemoteOptions::default()
542 },
543 true,
544 )
545 .unwrap_err();
546 assert!(err.to_string().contains("sync CLI"));
547
548 let runner2 = ScriptedRunner::default();
549 push_cli_probes_missing(&runner2);
550 let err = remote_doctor(
551 &runner2,
552 &RemoteOptions {
553 host: "box".into(),
554 ..RemoteOptions::default()
555 },
556 )
557 .unwrap_err();
558 assert!(err.to_string().contains("sync CLI"));
559 }
560
561 #[test]
562 fn remote_setup_status_and_doctor_return_reports_when_current() {
563 let runner = ScriptedRunner::default();
564 push_cli_probe_current(&runner);
565 runner.push(
566 "ssh",
567 ScriptedRunner::ok(
568 r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
569 ),
570 );
571 runner.push(
572 "ssh",
573 ScriptedRunner::ok(
574 r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":[]}"#,
575 ),
576 );
577 let status = remote_setup_status(
578 &runner,
579 &RemoteOptions {
580 host: "box".into(),
581 ..RemoteOptions::default()
582 },
583 true,
584 )
585 .unwrap();
586 assert_eq!(status.steps[0].id, "s1");
587
588 let runner2 = ScriptedRunner::default();
589 push_cli_probe_current(&runner2);
590 runner2.push(
591 "ssh",
592 ScriptedRunner::ok(
593 r#"{"kind":"full","steps":[{"id":"s1","title":"Base","status":"done","step_version":1,"destructive":false,"needs_reboot_after":false}]}"#,
594 ),
595 );
596 runner2.push(
597 "ssh",
598 ScriptedRunner::ok(
599 r#"{"is_root":false,"has_sudo":true,"docker_present":true,"active_setup_dir":true,"full_env":true,"minimal_env":false,"docker_dir":true,"backup_dir":true,"notes":[]}"#,
600 ),
601 );
602 let doc = remote_doctor(
603 &runner2,
604 &RemoteOptions {
605 host: "box".into(),
606 ..RemoteOptions::default()
607 },
608 )
609 .unwrap();
610 assert!(doc.has_sudo);
611 }
612
613 #[test]
614 fn build_remote_command_shapes() {
615 let cmd = build_remote_command_at(
616 "/tmp/agent/horto-os-ui",
617 &[
618 "--apply".into(),
619 "setup".into(),
620 "run".into(),
621 "--full".into(),
622 ],
623 false,
624 );
625 assert_eq!(cmd, "/tmp/agent/horto-os-ui --apply setup run --full");
626 let cmd_sudo = build_remote_command_at("/tmp/agent/horto-os-ui", &["doctor".into()], true);
627 assert!(cmd_sudo.starts_with("sudo "));
628 }
629
630 #[test]
631 fn remote_run_does_not_copy_id_by_default() {
632 let stubs = bin_dir_with_stubs();
633 let runner = ScriptedRunner::default();
634 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
636 push_cli_probes_missing(&runner);
637 runner.push("ssh", ScriptedRunner::ok(""));
639 runner.push("scp", ScriptedRunner::ok(""));
641 runner.push("ssh", ScriptedRunner::ok(""));
643 runner.push("ssh", ScriptedRunner::ok("ok\n"));
645
646 let log = remote_run_cli(
647 &runner,
648 &RemoteRunRequest {
649 options: RemoteOptions {
650 host: "box".into(),
651 bin_dir: Some(stubs.path().to_path_buf()),
652 install_ssh_key: false,
653 ..RemoteOptions::default()
654 },
655 cli_args: vec!["setup".into(), "status".into()],
656 flags: RemoteRunFlags {
657 use_sudo: false,
658 install_payload_on_success: false,
659 offer_reboot_on_success: false,
660 capture_output: false,
661 },
662 ecosystem: EcosystemInstallChoice::none(),
663 },
664 )
665 .unwrap()
666 .log;
667 assert!(log.contains("ok"));
668 let programs: Vec<_> = runner
669 .calls
670 .lock()
671 .unwrap()
672 .iter()
673 .map(|(p, _, _, _)| p.clone())
674 .collect();
675 assert!(!programs.iter().any(|p| p == "ssh-copy-id"));
676 }
677
678 #[test]
679 fn remote_run_install_key_when_opt_in() {
680 crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
681 let stubs = bin_dir_with_stubs();
682 let runner = ScriptedRunner::default();
683 runner.push("ssh", ScriptedRunner::ok("aarch64\n"));
684 push_cli_probes_missing(&runner);
685 runner.push("ssh", ScriptedRunner::ok(""));
686 runner.push("scp", ScriptedRunner::ok(""));
687 runner.push("ssh", ScriptedRunner::ok(""));
688 runner.push("ssh", ScriptedRunner::fail(255, "Permission denied"));
690 runner.push("ssh-copy-id", ScriptedRunner::ok(""));
691 runner.push("ssh", ScriptedRunner::ok("done\n"));
692
693 remote_run_cli(
694 &runner,
695 &RemoteRunRequest {
696 options: RemoteOptions {
697 host: "box".into(),
698 bin_dir: Some(stubs.path().to_path_buf()),
699 install_ssh_key: true,
700 ..RemoteOptions::default()
701 },
702 cli_args: vec!["doctor".into()],
703 flags: RemoteRunFlags {
704 use_sudo: false,
705 install_payload_on_success: false,
706 offer_reboot_on_success: false,
707 capture_output: false,
708 },
709 ecosystem: EcosystemInstallChoice::none(),
710 },
711 )
712 .unwrap();
713 assert!(runner
714 .calls
715 .lock()
716 .unwrap()
717 .iter()
718 .any(|(p, _, _, _)| p == "ssh-copy-id"));
719 });
720 }
721
722 #[test]
723 fn remote_run_skips_ssh_copy_id_when_key_works() {
724 crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
725 let stubs = bin_dir_with_stubs();
726 let runner = ScriptedRunner::default();
727 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
728 push_cli_probes_missing(&runner);
729 runner.push("ssh", ScriptedRunner::ok(""));
730 runner.push("scp", ScriptedRunner::ok(""));
731 runner.push("ssh", ScriptedRunner::ok(""));
732 runner.push("ssh", ScriptedRunner::ok(""));
734 runner.push("ssh", ScriptedRunner::ok("doctor ok\n"));
735
736 remote_run_cli(
737 &runner,
738 &RemoteRunRequest {
739 options: RemoteOptions {
740 host: "box".into(),
741 bin_dir: Some(stubs.path().to_path_buf()),
742 install_ssh_key: true,
743 ..RemoteOptions::default()
744 },
745 cli_args: vec!["doctor".into()],
746 flags: RemoteRunFlags {
747 use_sudo: false,
748 install_payload_on_success: false,
749 offer_reboot_on_success: false,
750 capture_output: false,
751 },
752 ecosystem: EcosystemInstallChoice::none(),
753 },
754 )
755 .unwrap();
756 assert!(!runner
757 .calls
758 .lock()
759 .unwrap()
760 .iter()
761 .any(|(p, _, _, _)| p == "ssh-copy-id"));
762 });
763 }
764
765 #[test]
766 fn default_options_key_off() {
767 assert!(!RemoteOptions::default().install_ssh_key);
768 }
769
770 #[test]
771 fn remote_options_from_input_force_askpass_tag_and_defaults() {
772 let opts = RemoteOptions::from_input(RemoteOptionsInput {
773 host: "box".into(),
774 install_ssh_key: true,
775 bin_dir: None,
776 release_tag: Some(" v9.9.9 ".into()),
777 force_askpass: true,
778 });
779 assert_eq!(opts.host, "box");
780 assert!(opts.install_ssh_key);
781 assert!(opts.force_askpass);
782 assert_eq!(opts.release_tag, "v9.9.9");
783 assert_eq!(opts.github_repo, DEFAULT_GITHUB_REPO);
784
785 let defaults = RemoteOptions::from_input(RemoteOptionsInput {
786 host: "h".into(),
787 ..RemoteOptionsInput::default()
788 });
789 assert!(!defaults.force_askpass);
790 assert!(!defaults.install_ssh_key);
791 assert_eq!(defaults.release_tag, RemoteOptions::default().release_tag);
792
793 let blank_tag = RemoteOptions::from_input(RemoteOptionsInput {
794 host: "h".into(),
795 release_tag: Some(" ".into()),
796 ..RemoteOptionsInput::default()
797 });
798 assert_eq!(blank_tag.release_tag, RemoteOptions::default().release_tag);
799 }
800
801 #[test]
802 fn remote_probe_arch_ok() {
803 let runner = ScriptedRunner::default();
804 runner.push("ssh", ScriptedRunner::ok("aarch64\n"));
805 let arch = remote_probe_arch(
806 &runner,
807 &RemoteOptions {
808 host: "box".into(),
809 ..RemoteOptions::default()
810 },
811 )
812 .unwrap();
813 assert_eq!(arch, BoxArch::Arm64);
814 }
815
816 #[test]
817 fn remote_setup_run_plan_minimal_skip_piper() {
818 let stubs = bin_dir_with_stubs();
819 let runner = ScriptedRunner::default();
820 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
821 push_cli_probes_missing(&runner);
822 runner.push("ssh", ScriptedRunner::ok(""));
823 runner.push("scp", ScriptedRunner::ok(""));
824 runner.push("ssh", ScriptedRunner::ok(""));
825 runner.push("ssh", ScriptedRunner::ok("pipeline ok\n"));
826 let log = remote_setup_run(
827 &runner,
828 RemoteOptions {
829 host: "box".into(),
830 bin_dir: Some(stubs.path().to_path_buf()),
831 ..RemoteOptions::default()
832 },
833 false,
834 false,
835 true,
836 EcosystemInstallChoice::none(),
837 )
838 .unwrap()
839 .log;
840 assert!(log.contains("pipeline ok") || log.contains("remote command"));
841 let cli_ssh = runner
842 .calls
843 .lock()
844 .unwrap()
845 .iter()
846 .rev()
847 .find(|(p, _, _, _)| p == "ssh")
848 .unwrap()
849 .1
850 .join(" ");
851 assert!(!cli_ssh.contains("--apply"));
852 assert!(cli_ssh.contains("--skip-piper"));
853 assert!(cli_ssh.contains("--minimal"));
854 }
855
856 #[test]
857 fn remote_run_merges_stderr_and_empty_inherit_log() {
858 let stubs = bin_dir_with_stubs();
859 let runner = ScriptedRunner::default();
860 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
861 push_cli_probes_missing(&runner);
862 runner.push("ssh", ScriptedRunner::ok(""));
863 runner.push("scp", ScriptedRunner::ok(""));
864 runner.push("ssh", ScriptedRunner::ok(""));
865 runner.push(
866 "ssh",
867 CommandOutput {
868 status: 0,
869 stdout: "out\n".into(),
870 stderr: "warn\n".into(),
871 },
872 );
873 let log = remote_run_cli(
874 &runner,
875 &RemoteRunRequest {
876 options: RemoteOptions {
877 host: "box".into(),
878 bin_dir: Some(stubs.path().to_path_buf()),
879 ..RemoteOptions::default()
880 },
881 cli_args: vec!["doctor".into()],
882 flags: RemoteRunFlags {
883 use_sudo: false,
884 install_payload_on_success: false,
885 offer_reboot_on_success: false,
886 capture_output: false,
887 },
888 ecosystem: EcosystemInstallChoice::none(),
889 },
890 )
891 .unwrap()
892 .log;
893 assert!(log.contains("out"));
894 assert!(log.contains("warn"));
895
896 let runner2 = ScriptedRunner::default();
897 runner2.push("ssh", ScriptedRunner::ok("x86_64\n"));
898 push_cli_probes_missing(&runner2);
899 runner2.push("ssh", ScriptedRunner::ok(""));
900 runner2.push("scp", ScriptedRunner::ok(""));
901 runner2.push("ssh", ScriptedRunner::ok(""));
902 runner2.push("ssh", ScriptedRunner::ok(""));
903 let log2 = remote_run_cli(
904 &runner2,
905 &RemoteRunRequest {
906 options: RemoteOptions {
907 host: "box".into(),
908 bin_dir: Some(stubs.path().to_path_buf()),
909 ..RemoteOptions::default()
910 },
911 cli_args: vec!["doctor".into()],
912 flags: RemoteRunFlags {
913 use_sudo: false,
914 install_payload_on_success: false,
915 offer_reboot_on_success: false,
916 capture_output: false,
917 },
918 ecosystem: EcosystemInstallChoice::none(),
919 },
920 )
921 .unwrap()
922 .log;
923 assert!(log2.contains("remote command finished"));
924 }
925
926 #[test]
927 fn remote_install_payload_scp_and_custom_prefix() {
928 let stubs = bin_dir_with_stubs();
929 let bins = LocalBins {
930 dir: stubs.path().to_path_buf(),
931 cli: stubs.path().join("horto-os-ui"),
932 tui: stubs.path().join("horto-os-ui-tui"),
933 status_api: stubs.path().join("horto-os-ui-status-api"),
934 mcp: Some(stubs.path().join("horto-os-ui-mcp")),
935 };
936 let runner = ScriptedRunner::default();
937 runner.push("ssh", ScriptedRunner::ok(""));
939 runner.push("rsync", ScriptedRunner::fail(127, "no"));
941 runner.push("ssh", ScriptedRunner::ok(""));
943 runner.push("scp", ScriptedRunner::ok(""));
944 runner.push("scp", ScriptedRunner::ok(""));
945 runner.push("scp", ScriptedRunner::ok(""));
946 runner.push("scp", ScriptedRunner::ok(""));
947 runner.push("ssh", ScriptedRunner::ok(""));
949 runner.push(
950 "ssh",
951 ScriptedRunner::ok("HORTO_API_TOKEN=deadbeefcafebabedeadbeefcafebabe\n"),
952 );
953 runner.push("ssh", ScriptedRunner::ok(""));
954
955 let token = remote_install_payload(
956 &runner,
957 &RemoteOptions {
958 host: "box".into(),
959 install_dir: "/opt/horto/bin".into(),
960 ..RemoteOptions::default()
961 },
962 &bins,
963 EcosystemInstallChoice {
964 status_api: true,
965 mcp: true,
966 },
967 )
968 .unwrap();
969 assert_eq!(token.as_deref(), Some("deadbeefcafebabedeadbeefcafebabe"));
970 let ssh_cmds: Vec<String> = runner
971 .calls
972 .lock()
973 .unwrap()
974 .iter()
975 .filter(|(p, _, _, _)| p == "ssh")
976 .map(|(_, args, _, _)| args.join(" "))
977 .collect();
978 assert!(ssh_cmds
979 .iter()
980 .any(|c| c.contains("horto-os-ui-status-api.service")
981 && c.contains("horto-os-ui-mcp.service")
982 && c.contains("/opt/horto/bin")
983 && c.contains("restart horto-os-ui-mcp.service")));
984 assert!(ssh_cmds.len() >= 5);
985 }
986
987 #[test]
988 fn remote_run_with_payload_install() {
989 let stubs = bin_dir_with_stubs();
990 let runner = ScriptedRunner::default();
991 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
992 push_cli_probes_missing(&runner);
993 runner.push("ssh", ScriptedRunner::ok(""));
994 runner.push("scp", ScriptedRunner::ok(""));
995 runner.push("ssh", ScriptedRunner::ok(""));
996 runner.push("ssh", ScriptedRunner::ok("done\n"));
997 runner.push("ssh", ScriptedRunner::ok(""));
999 runner.push("rsync", ScriptedRunner::fail(127, "no"));
1000 runner.push("ssh", ScriptedRunner::ok(""));
1001 runner.push("scp", ScriptedRunner::ok(""));
1002 runner.push("scp", ScriptedRunner::ok(""));
1003 runner.push("scp", ScriptedRunner::ok(""));
1004 runner.push("scp", ScriptedRunner::ok(""));
1005 runner.push("ssh", ScriptedRunner::ok(""));
1007 runner.push(
1008 "ssh",
1009 ScriptedRunner::ok("aabbccddeeff00112233445566778899\n"),
1010 );
1011 runner.push("ssh", ScriptedRunner::ok(""));
1012
1013 let outcome = remote_run_cli(
1014 &runner,
1015 &RemoteRunRequest {
1016 options: RemoteOptions {
1017 host: "box".into(),
1018 bin_dir: Some(stubs.path().to_path_buf()),
1019 ..RemoteOptions::default()
1020 },
1021 cli_args: vec!["setup".into(), "run".into(), "--full".into()],
1022 flags: RemoteRunFlags {
1023 use_sudo: true,
1024 install_payload_on_success: true,
1025 offer_reboot_on_success: false,
1026 capture_output: false,
1027 },
1028 ecosystem: EcosystemInstallChoice {
1029 status_api: true,
1030 mcp: true,
1031 },
1032 },
1033 )
1034 .unwrap();
1035 assert!(outcome.log.contains("done"));
1036 assert_eq!(
1037 outcome.api_token.as_deref(),
1038 Some("aabbccddeeff00112233445566778899")
1039 );
1040 }
1041
1042 #[test]
1043 fn remote_run_payload_skipped_when_ecosystem_none() {
1044 let stubs = bin_dir_with_stubs();
1045 let runner = ScriptedRunner::default();
1046 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1047 push_cli_probes_missing(&runner);
1048 runner.push("ssh", ScriptedRunner::ok(""));
1049 runner.push("scp", ScriptedRunner::ok(""));
1050 runner.push("ssh", ScriptedRunner::ok(""));
1051 runner.push("ssh", ScriptedRunner::ok("done\n"));
1052
1053 let outcome = remote_run_cli(
1054 &runner,
1055 &RemoteRunRequest {
1056 options: RemoteOptions {
1057 host: "box".into(),
1058 bin_dir: Some(stubs.path().to_path_buf()),
1059 ..RemoteOptions::default()
1060 },
1061 cli_args: vec!["setup".into(), "run".into(), "--full".into()],
1062 flags: RemoteRunFlags {
1063 use_sudo: true,
1064 install_payload_on_success: true,
1065 offer_reboot_on_success: false,
1066 capture_output: false,
1067 },
1068 ecosystem: EcosystemInstallChoice::none(),
1069 },
1070 )
1071 .unwrap();
1072 assert!(outcome.log.contains("done"));
1073 assert!(outcome.api_token.is_none());
1074 let scp = runner
1075 .calls
1076 .lock()
1077 .unwrap()
1078 .iter()
1079 .filter(|(p, _, _, _)| p == "scp" || p == "rsync")
1080 .count();
1081 assert_eq!(scp, 1);
1083 }
1084
1085 #[test]
1086 fn parse_api_token_drop_accepts_prefix_and_bare_hex() {
1087 assert_eq!(
1088 parse_api_token_drop("HORTO_API_TOKEN=aabbccddeeff00112233445566778899\n").as_deref(),
1089 Some("aabbccddeeff00112233445566778899")
1090 );
1091 assert_eq!(
1092 parse_api_token_drop(" deadbeefcafebabedeadbeefcafebabe \n").as_deref(),
1093 Some("deadbeefcafebabedeadbeefcafebabe")
1094 );
1095 assert!(parse_api_token_drop("").is_none());
1096 assert!(parse_api_token_drop("not-hex!").is_none());
1097 assert!(parse_api_token_drop("HORTO_API_TOKEN=abc123\n").is_none());
1098 assert!(parse_api_token_drop("11223344\n").is_none());
1099 }
1100
1101 #[test]
1102 fn finish_save_api_token_writes_under_xdg_config() {
1103 let _guard = CONFIG_ENV_LOCK
1104 .lock()
1105 .unwrap_or_else(std::sync::PoisonError::into_inner);
1106 let tmp = TempDir::new().unwrap();
1107 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1108 assert!(!finish_save_api_token("aabb", "n").unwrap());
1109 assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1110 assert!(finish_save_api_token("aabbccddeeff00112233445566778899", "y").unwrap());
1111 let path = tmp.path().join("horto-os-ui").join("api_token");
1112 let body = fs::read_to_string(&path).unwrap();
1113 assert_eq!(body.trim(), "aabbccddeeff00112233445566778899");
1114 }
1115
1116 #[test]
1117 fn write_api_token_file_uses_home_when_xdg_empty() {
1118 let _guard = CONFIG_ENV_LOCK
1119 .lock()
1120 .unwrap_or_else(std::sync::PoisonError::into_inner);
1121 let tmp = TempDir::new().unwrap();
1122 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", " ");
1123 let _home = EnvVarGuard::set("HOME", tmp.path());
1124 let path = write_api_token_file("11223344556677889900aabbccddeeff").unwrap();
1125 assert_eq!(
1126 path,
1127 tmp.path()
1128 .join(".config")
1129 .join("horto-os-ui")
1130 .join("api_token")
1131 );
1132 assert_eq!(
1133 fs::read_to_string(&path).unwrap().trim(),
1134 "11223344556677889900aabbccddeeff"
1135 );
1136 }
1137
1138 #[test]
1139 fn write_api_token_file_uses_home_when_xdg_unset() {
1140 let _guard = CONFIG_ENV_LOCK
1141 .lock()
1142 .unwrap_or_else(std::sync::PoisonError::into_inner);
1143 let tmp = TempDir::new().unwrap();
1144 let _xdg = EnvVarGuard::remove("XDG_CONFIG_HOME");
1145 let _home = EnvVarGuard::set("HOME", tmp.path());
1146 let path = write_api_token_file("556677889900aabbccddeeff11223344").unwrap();
1147 assert!(path.ends_with("horto-os-ui/api_token"));
1148 assert_eq!(
1149 fs::read_to_string(&path).unwrap().trim(),
1150 "556677889900aabbccddeeff11223344"
1151 );
1152 }
1153
1154 #[test]
1155 fn offer_save_api_token_non_tty_skips_write() {
1156 let _guard = CONFIG_ENV_LOCK
1157 .lock()
1158 .unwrap_or_else(std::sync::PoisonError::into_inner);
1159 let tmp = TempDir::new().unwrap();
1160 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1161 assert!(
1162 !offer_save_api_token_with("99aabbccddeeff001122334455667788", false, None).unwrap()
1163 );
1164 assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1165 let _ = offer_save_api_token("99aabbccddeeff001122334455667788");
1167 }
1168
1169 #[test]
1170 fn offer_save_api_token_tty_canned_yes_and_no() {
1171 let _guard = CONFIG_ENV_LOCK
1172 .lock()
1173 .unwrap_or_else(std::sync::PoisonError::into_inner);
1174 let tmp = TempDir::new().unwrap();
1175 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1176 assert!(!offer_save_api_token_with("aa11", true, Some("n")).unwrap());
1177 assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1178 assert!(
1179 offer_save_api_token_with("bb22cc33ddeeff001122334455667788", true, Some("yes"))
1180 .unwrap()
1181 );
1182 let body = fs::read_to_string(tmp.path().join("horto-os-ui").join("api_token")).unwrap();
1183 assert_eq!(body.trim(), "bb22cc33ddeeff001122334455667788");
1184 }
1185
1186 #[test]
1187 fn parse_api_token_drop_rejects_empty_hex_value() {
1188 assert!(parse_api_token_drop("HORTO_API_TOKEN=\n").is_none());
1189 assert!(parse_api_token_drop("HORTO_API_TOKEN= \n").is_none());
1190 assert_eq!(
1191 parse_api_token_drop("\n\nHORTO_API_TOKEN=aabbccddeeff00112233445566778899\n")
1192 .as_deref(),
1193 Some("aabbccddeeff00112233445566778899")
1194 );
1195 }
1196
1197 #[test]
1198 fn write_api_token_file_rejects_short_before_create() {
1199 let _guard = CONFIG_ENV_LOCK
1200 .lock()
1201 .unwrap_or_else(std::sync::PoisonError::into_inner);
1202 let tmp = TempDir::new().unwrap();
1203 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1204 let err = write_api_token_file("11223344").unwrap_err();
1205 assert!(err.to_string().contains("at least"));
1206 assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1207 }
1208
1209 #[test]
1210 fn pull_remote_api_token_rejects_empty_host() {
1211 let runner = ScriptedRunner::default();
1212 let err = pull_remote_api_token(
1213 &runner,
1214 &RemoteOptions {
1215 host: " ".into(),
1216 ..RemoteOptions::default()
1217 },
1218 )
1219 .unwrap_err();
1220 assert!(err.to_string().contains("empty"));
1221 }
1222
1223 #[test]
1224 fn pull_remote_api_token_writes_tip_from_drop() {
1225 let _guard = CONFIG_ENV_LOCK
1226 .lock()
1227 .unwrap_or_else(std::sync::PoisonError::into_inner);
1228 let tmp = TempDir::new().unwrap();
1229 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1230 let runner = ScriptedRunner::default();
1231 runner.push("ssh", ScriptedRunner::ok(""));
1233 runner.push(
1235 "ssh",
1236 ScriptedRunner::ok("HORTO_API_TOKEN=aabbccddeeff00112233445566778899\n"),
1237 );
1238 let tok = pull_remote_api_token(
1239 &runner,
1240 &RemoteOptions {
1241 host: "box".into(),
1242 ..RemoteOptions::default()
1243 },
1244 )
1245 .unwrap();
1246 assert_eq!(tok, "aabbccddeeff00112233445566778899");
1247 let tip = tmp.path().join("horto-os-ui").join("api_token");
1248 assert_eq!(fs::read_to_string(&tip).unwrap().trim(), tok);
1249 }
1250
1251 #[test]
1252 fn pull_remote_api_token_errors_when_drop_unusable() {
1253 let _guard = CONFIG_ENV_LOCK
1254 .lock()
1255 .unwrap_or_else(std::sync::PoisonError::into_inner);
1256 let tmp = TempDir::new().unwrap();
1257 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", tmp.path());
1258 let runner = ScriptedRunner::default();
1259 runner.push("ssh", ScriptedRunner::ok(""));
1260 runner.push("ssh", ScriptedRunner::ok("HORTO_API_TOKEN=short\n"));
1261 let err = pull_remote_api_token(
1262 &runner,
1263 &RemoteOptions {
1264 host: "box".into(),
1265 ..RemoteOptions::default()
1266 },
1267 )
1268 .unwrap_err();
1269 assert!(err.to_string().contains("could not read Status API token"));
1270 assert!(!tmp.path().join("horto-os-ui").join("api_token").exists());
1271 }
1272
1273 #[test]
1274 fn write_api_token_file_errors_when_config_parent_blocked() {
1275 let _guard = CONFIG_ENV_LOCK
1276 .lock()
1277 .unwrap_or_else(std::sync::PoisonError::into_inner);
1278 let tmp = TempDir::new().unwrap();
1279 let blocker = tmp.path().join("blocked");
1280 fs::write(&blocker, b"not-a-directory").unwrap();
1281 let _xdg = EnvVarGuard::set("XDG_CONFIG_HOME", &blocker);
1282 let err = write_api_token_file("aabbccddeeff00112233445566778899").unwrap_err();
1283 assert!(err.to_string().contains("create"));
1284 }
1285
1286 #[test]
1287 fn remote_install_payload_default_prefix_none_token() {
1288 let stubs = bin_dir_with_stubs();
1289 let bins = LocalBins {
1290 dir: stubs.path().to_path_buf(),
1291 cli: stubs.path().join("horto-os-ui"),
1292 tui: stubs.path().join("horto-os-ui-tui"),
1293 status_api: stubs.path().join("horto-os-ui-status-api"),
1294 mcp: Some(stubs.path().join("horto-os-ui-mcp")),
1295 };
1296 let runner = ScriptedRunner::default();
1297 runner.push("ssh", ScriptedRunner::ok(""));
1298 runner.push("rsync", ScriptedRunner::fail(127, "no"));
1299 runner.push("ssh", ScriptedRunner::ok(""));
1300 runner.push("scp", ScriptedRunner::ok(""));
1301 runner.push("scp", ScriptedRunner::ok(""));
1302 runner.push("scp", ScriptedRunner::ok(""));
1303 runner.push("scp", ScriptedRunner::ok(""));
1304 runner.push("ssh", ScriptedRunner::ok(""));
1306 runner.push("ssh", ScriptedRunner::ok("\n"));
1307 runner.push("ssh", ScriptedRunner::ok(""));
1308
1309 let token = remote_install_payload(
1310 &runner,
1311 &RemoteOptions {
1312 host: "box".into(),
1313 ..RemoteOptions::default()
1314 },
1315 &bins,
1316 EcosystemInstallChoice {
1317 status_api: true,
1318 mcp: true,
1319 },
1320 )
1321 .unwrap();
1322 assert!(token.is_none());
1323 }
1324
1325 #[test]
1326 fn remote_setup_run_full_apply_captures_token() {
1327 let stubs = bin_dir_with_stubs();
1328 let runner = ScriptedRunner::default();
1329 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1330 push_cli_probes_missing(&runner);
1331 runner.push("ssh", ScriptedRunner::ok(""));
1332 runner.push("scp", ScriptedRunner::ok(""));
1333 runner.push("ssh", ScriptedRunner::ok(""));
1334 runner.push("ssh", ScriptedRunner::ok("setup ok\n"));
1335 runner.push("ssh", ScriptedRunner::ok(""));
1337 runner.push("rsync", ScriptedRunner::fail(127, "no"));
1338 runner.push("ssh", ScriptedRunner::ok(""));
1339 runner.push("scp", ScriptedRunner::ok(""));
1340 runner.push("scp", ScriptedRunner::ok(""));
1341 runner.push("scp", ScriptedRunner::ok(""));
1342 runner.push("scp", ScriptedRunner::ok(""));
1343 runner.push("ssh", ScriptedRunner::ok(""));
1344 runner.push(
1345 "ssh",
1346 ScriptedRunner::ok("HORTO_API_TOKEN=ffeeddccbbaa99887766554433221100\n"),
1347 );
1348 runner.push("ssh", ScriptedRunner::ok(""));
1349
1350 let outcome = remote_setup_run(
1351 &runner,
1352 RemoteOptions {
1353 host: "box".into(),
1354 bin_dir: Some(stubs.path().to_path_buf()),
1355 ..RemoteOptions::default()
1356 },
1357 true,
1358 true,
1359 false,
1360 EcosystemInstallChoice {
1361 status_api: true,
1362 mcp: true,
1363 },
1364 )
1365 .unwrap();
1366 assert!(outcome.log.contains("setup ok"));
1367 assert_eq!(
1368 outcome.api_token.as_deref(),
1369 Some("ffeeddccbbaa99887766554433221100")
1370 );
1371 let cli_ssh = runner
1372 .calls
1373 .lock()
1374 .unwrap()
1375 .iter()
1376 .find(|(p, args, _, _)| p == "ssh" && args.iter().any(|a| a.contains("--full")))
1377 .map(|(_, args, _, _)| args.join(" "))
1378 .unwrap_or_default();
1379 assert!(cli_ssh.contains("--full"));
1380 assert!(cli_ssh.contains("--apply"));
1381 }
1382
1383 #[test]
1384 fn remote_run_outcome_default_is_empty() {
1385 let o = RemoteRunOutcome::default();
1386 assert_eq!(o.log, "");
1387 assert!(o.api_token.is_none());
1388 }
1389
1390 #[test]
1391 fn wants_reboot_now_parses_answers() {
1392 assert!(wants_reboot_now("y"));
1393 assert!(wants_reboot_now("YES"));
1394 assert!(!wants_reboot_now(""));
1395 assert!(!wants_reboot_now("n"));
1396 assert!(!wants_reboot_now("maybe"));
1397 }
1398
1399 #[test]
1400 fn ssh_drop_after_reboot_matches_expected_phrases() {
1401 for msg in [
1402 "Connection closed by remote host",
1403 "Connection reset by peer",
1404 "Broken pipe",
1405 "ssh: exit 255",
1406 ] {
1407 assert!(
1408 super::reboot::ssh_drop_after_reboot(&crate::error::HortoError::msg(msg)),
1409 "expected drop: {msg}"
1410 );
1411 }
1412 assert!(!super::reboot::ssh_drop_after_reboot(
1413 &crate::error::HortoError::msg("Sorry, try again.")
1414 ));
1415 }
1416
1417 fn test_session() -> SshSession {
1418 session_from(&RemoteOptions {
1419 host: "box".into(),
1420 ..RemoteOptions::default()
1421 })
1422 .unwrap()
1423 }
1424
1425 #[test]
1426 fn finish_reboot_no_skips_ssh() {
1427 let runner = ScriptedRunner::default();
1428 finish_remote_reboot(&runner, &test_session(), "n", None).unwrap();
1429 assert!(runner.calls.lock().unwrap().is_empty());
1430 }
1431
1432 #[test]
1433 fn finish_reboot_yes_runs_sudo_reboot() {
1434 let runner = ScriptedRunner::default();
1435 runner.push("ssh", ScriptedRunner::ok(""));
1436 finish_remote_reboot(&runner, &test_session(), "yes", None).unwrap();
1437 let calls = runner.calls.lock().unwrap();
1438 assert_eq!(calls.len(), 1);
1439 assert!(calls[0].1.iter().any(|a| a.contains("sudo reboot")));
1440 drop(calls);
1441 }
1442
1443 #[test]
1444 fn finish_reboot_yes_treats_ssh_drop_as_ok() {
1445 let runner = ScriptedRunner::default();
1446 runner.push("ssh", ScriptedRunner::fail(255, "Connection closed"));
1447 finish_remote_reboot(&runner, &test_session(), "y", None).unwrap();
1448 }
1449
1450 #[test]
1451 fn finish_reboot_with_password_feeds_sudo_dash_s() {
1452 let runner = ScriptedRunner::default();
1453 runner.push("ssh", ScriptedRunner::ok(""));
1454 finish_remote_reboot(&runner, &test_session(), "y", Some("pw")).unwrap();
1455 let calls = runner.calls.lock().unwrap();
1456 assert_eq!(calls.len(), 1);
1457 assert!(calls[0].1.iter().any(|a| a.contains("sudo -S reboot")));
1458 assert!(!calls[0].1.iter().any(|a| *a == "-tt"));
1459 drop(calls);
1460 }
1461
1462 #[test]
1463 fn finish_reboot_with_password_propagates_sudo_failure() {
1464 let runner = ScriptedRunner::default();
1465 runner.push("ssh", ScriptedRunner::fail(1, "Sorry, try again."));
1466 let err = finish_remote_reboot(&runner, &test_session(), "y", Some("bad")).unwrap_err();
1467 assert!(err.to_string().contains("Sorry") || err.to_string().contains("exit 1"));
1468 }
1469
1470 #[test]
1471 fn finish_reboot_with_password_treats_ssh_drop_as_ok() {
1472 for stderr in [
1473 "Connection closed by remote host",
1474 "Connection reset by peer",
1475 "Broken pipe",
1476 "ssh: exit 255",
1477 ] {
1478 let runner = ScriptedRunner::default();
1479 runner.push("ssh", ScriptedRunner::fail(255, stderr));
1480 finish_remote_reboot(&runner, &test_session(), "y", Some("pw")).unwrap();
1481 }
1482 }
1483
1484 #[test]
1485 fn remote_reboot_with_sudo_password_wrapper() {
1486 let runner = ScriptedRunner::default();
1487 runner.push("ssh", ScriptedRunner::ok(""));
1488 let opts = RemoteOptions {
1489 host: "box".into(),
1490 ..RemoteOptions::default()
1491 };
1492 remote_reboot_with_sudo_password(&runner, &opts, "secret").unwrap();
1493 let calls = runner.calls.lock().unwrap();
1494 assert!(calls[0].1.iter().any(|a| a.contains("sudo -S reboot")));
1495 drop(calls);
1496 }
1497
1498 #[test]
1499 fn remote_ensure_ssh_key_and_reboot_wrappers() {
1500 crate::remote::ssh::tests::with_fake_default_pubkey(|_| {
1501 let runner = ScriptedRunner::default();
1502 runner.push("ssh", ScriptedRunner::ok(""));
1504 let opts = RemoteOptions {
1505 host: "box".into(),
1506 ..RemoteOptions::default()
1507 };
1508 remote_ensure_ssh_key(&runner, &opts).unwrap();
1509
1510 let runner2 = ScriptedRunner::default();
1511 runner2.push("ssh", ScriptedRunner::ok(""));
1512 remote_reboot(&runner2, &opts).unwrap();
1513 });
1514 }
1515
1516 #[test]
1517 fn remote_run_offers_reboot_on_success_non_tty() {
1518 let stubs = bin_dir_with_stubs();
1519 let runner = ScriptedRunner::default();
1520 runner.push("ssh", ScriptedRunner::ok("x86_64\n"));
1521 push_cli_probes_missing(&runner);
1522 runner.push("ssh", ScriptedRunner::ok(""));
1523 runner.push("scp", ScriptedRunner::ok(""));
1524 runner.push("ssh", ScriptedRunner::ok(""));
1525 runner.push("ssh", ScriptedRunner::ok("done\n"));
1526 remote_run_cli(
1527 &runner,
1528 &RemoteRunRequest {
1529 options: RemoteOptions {
1530 host: "box".into(),
1531 bin_dir: Some(stubs.path().to_path_buf()),
1532 ..RemoteOptions::default()
1533 },
1534 cli_args: vec!["doctor".into()],
1535 flags: RemoteRunFlags {
1536 use_sudo: false,
1537 install_payload_on_success: false,
1538 offer_reboot_on_success: true,
1539 capture_output: false,
1540 },
1541 ecosystem: EcosystemInstallChoice::none(),
1542 },
1543 )
1544 .unwrap();
1545 }
1546}