//! End-to-end tests for the production update flows (`check_update_status`, //! `ensure_latest_on_disk`, `run_update`, `run_update_if_available`) against //! a GitHub-Releases-shaped [`common::artifact_server::ArtifactServer`], //! injected via the `KIGI_UPDATE_BASE_URL` override that //! `kigi_env::update_base_url()` honors. //! //! Three invariant families: //! //! 1. **Convergence**: a binary already on disk (installed by another //! process) is never downloaded a second time, but stale runners still //! get the relaunch/report signal. //! 2. **Status**: `kigi update --check` reports upgrades only, surfaces //! fetch errors in the `error` field, and never advertises downgrades. //! 3. **Race integrity**: concurrent installers — even for different //! versions — never leave a corrupt active binary. //! //! Everything here is `#[serial]`: KIGI_SHARE_DIR, KIGI_UPDATE_BASE_URL and //! KIGI_TEST_VERSION are process-global. #![cfg(unix)] mod common; use std::os::unix::fs::PermissionsExt; use std::path::Path; use serial_test::serial; use common::artifact_server::ArtifactServer; use common::{ can_exec_shell_scripts, host_platform, make_update_config, reset_home, set_test_version, set_update_base, small_good_artifact, test_home, }; use kigi_update::auto_update::{ UpdateRunMode, check_update_status, ensure_latest_on_disk, install_internal_from_base, run_update, run_update_if_available, }; use kigi_update::version::installed_on_disk_version; /// Lay down a managed-install layout in the test KIGI_SHARE_DIR: /// `bin/kigi -> ../downloads/kigi--` (what the installer /// produces; the canonical link the disk-version probe reads). fn fake_managed_install(version: &str) { let home = test_home(); let downloads = home.join("downloads"); let bin = home.join("bin"); std::fs::create_dir_all(&downloads).unwrap(); std::fs::create_dir_all(&bin).unwrap(); let name = format!("kigi-{version}-{}", host_platform()); std::fs::write(downloads.join(&name), small_good_artifact()).unwrap(); std::fs::set_permissions( downloads.join(&name), std::fs::Permissions::from_mode(0o755), ) .unwrap(); let _ = std::fs::remove_file(bin.join("kigi")); std::os::unix::fs::symlink(Path::new("../downloads").join(&name), bin.join("kigi")).unwrap(); } /// Assert the active `~/.kigi/bin/kigi` resolves to the expected versioned /// binary, actually runs, and has exactly the expected content (the content /// check is what catches a cross-racer temp-file corruption). fn assert_active_binary(home: &Path, version: &str, platform: &str, expected_content: &[u8]) { let link = home.join("bin").join("kigi"); assert!(link.is_symlink(), "kigi must be a symlink"); let resolved = dunce::canonicalize(&link) .unwrap_or_else(|e| panic!("active kigi symlink does not resolve: {e}")); assert_eq!( resolved.file_name().unwrap().to_string_lossy(), format!("kigi-{version}-{platform}"), "active kigi must be the expected version" ); assert_eq!( std::fs::read(&resolved).unwrap(), expected_content, "active binary content must be exactly the served artifact (no \ partial/interleaved writes from a racing updater)" ); let ran_ok = std::process::Command::new(&resolved) .arg("--version") .stdin(std::process::Stdio::null()) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false); assert!(ran_ok, "active kigi must pass the smoke-test"); } fn setup(server: &ArtifactServer, latest: &str, running: &str) { let _ = test_home(); reset_home(); server.set_latest(latest); set_update_base(&server.base()); set_test_version(running); } // ───────────────────────────────────────────────────────────────────────────── // Convergence: ensure_latest_on_disk downloads once, then every subsequent // pass (the leader's hourly re-entry) converges without re-downloading. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn ensure_latest_downloads_once_then_converges_without_redownload() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.5"); let cfg = make_update_config("stable"); // Pass 1: disk is empty → downloads and installs. let first = ensure_latest_on_disk(&cfg).await.unwrap(); assert_eq!(first.installed.as_deref(), Some("0.2.7")); assert!(first.relaunch_needed, "running 0.2.5 < disk 0.2.7"); assert_eq!(server.request_count(), 1, "first pass downloads"); assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7")); // Pass 2 (the pre-fix hourly re-download): disk already current → // no download, but the stale running process still gets the relaunch // signal. let second = ensure_latest_on_disk(&cfg).await.unwrap(); assert_eq!(second.installed, None, "second pass must not re-download"); assert!(second.relaunch_needed, "still running 0.2.5 < disk 0.2.7"); assert_eq!( server.request_count(), 1, "hourly re-entry must not download again" ); } #[tokio::test] #[serial] async fn run_update_skips_download_when_disk_already_current() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.5"); // Another process (TUI background download) already installed 0.2.7. fake_managed_install("0.2.7"); let mut cfg = make_update_config("stable"); let result = run_update(false, None, None, &mut cfg).await.unwrap(); assert_eq!( result.as_deref(), Some("0.2.7"), "run_update must still report the on-disk target so the caller \ signals stale leaders to relaunch" ); assert_eq!( server.request_count(), 0, "a binary someone else installed must not be downloaded again" ); } #[tokio::test] #[serial] async fn run_update_force_still_redownloads_when_disk_current() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.7"); fake_managed_install("0.2.7"); let mut cfg = make_update_config("stable"); let result = run_update(true, None, None, &mut cfg).await.unwrap(); assert_eq!(result.as_deref(), Some("0.2.7")); assert_eq!( server.request_count(), 1, "--force must bypass the disk-current skip and reinstall" ); } #[tokio::test] #[serial] async fn run_update_rolls_back_when_latest_moved_backwards() { // Release rollback: the latest release points BELOW the on-disk install // (a bad release was deleted). The internal installer is authoritative, // so run_update must converge the disk down to it. if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.5", "0.2.7"); fake_managed_install("0.2.7"); let mut cfg = make_update_config("stable"); let result = run_update(false, None, None, &mut cfg).await.unwrap(); assert_eq!( result.as_deref(), Some("0.2.5"), "rollback target installed" ); assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.5")); assert_eq!(server.request_count(), 1); } // ───────────────────────────────────────────────────────────────────────────── // Disk-version probe // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn disk_probe_preserves_prerelease_versions() { let _ = test_home(); reset_home(); // An alpha install must read back as the full pre-release version — // truncating to "0.1.220" would mask the alpha → stable update. fake_managed_install("0.1.220-alpha.4"); assert_eq!( installed_on_disk_version().as_deref(), Some("0.1.220-alpha.4") ); } #[tokio::test] #[serial] async fn disk_probe_rejects_dangling_symlink() { // If the symlink survives but its target binary was deleted (manual // ~/.kigi/downloads cleanup), the probe must report None — otherwise // every updater would claim "already up to date" forever while no // runnable binary exists, and nothing would ever repair the install. let home = test_home(); reset_home(); let platform = host_platform(); fake_managed_install("0.2.7"); assert_eq!(installed_on_disk_version().as_deref(), Some("0.2.7")); std::fs::remove_file( home.join("downloads") .join(format!("kigi-0.2.7-{platform}")), ) .unwrap(); assert_eq!( installed_on_disk_version(), None, "a dangling symlink must not report an installed version" ); } #[tokio::test] #[serial] async fn ensure_latest_repairs_dangling_symlink_by_downloading() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } // Dangling symlink + stale running process: the probe returns None, so // the decision falls back to the running version and the download runs, // repairing the install instead of wedging on "already up to date". let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.5"); let home = test_home(); let platform = host_platform(); fake_managed_install("0.2.7"); std::fs::remove_file( home.join("downloads") .join(format!("kigi-0.2.7-{platform}")), ) .unwrap(); let cfg = make_update_config("stable"); let outcome = ensure_latest_on_disk(&cfg).await.unwrap(); assert_eq!( outcome.installed.as_deref(), Some("0.2.7"), "dangling symlink must be repaired by an actual download" ); assert_eq!(server.request_count(), 1); assert_eq!( installed_on_disk_version().as_deref(), Some("0.2.7"), "probe healthy again after the repair install" ); } // ───────────────────────────────────────────────────────────────────────────── // check_update_status (`kigi update --check`) // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn check_status_reports_update_when_release_is_newer() { let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.5"); let cfg = make_update_config("stable"); let status = check_update_status(&cfg).await; assert_eq!(status.current_version, "0.2.5"); assert_eq!(status.latest_version.as_deref(), Some("0.2.7")); assert!(status.update_available); assert_eq!(status.installer.as_deref(), Some("internal")); assert_eq!(status.error, None); } #[tokio::test] #[serial] async fn check_status_never_reports_downgrade_as_update() { // --check reports upgrades only; a rolled-back release is not advertised // (auto-update converges separately). let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.5", "0.2.7"); let cfg = make_update_config("stable"); let status = check_update_status(&cfg).await; assert_eq!(status.latest_version.as_deref(), Some("0.2.5")); assert!(!status.update_available, "downgrade must not be advertised"); assert_eq!(status.error, None); } #[tokio::test] #[serial] async fn check_status_surfaces_fetch_error_in_error_field() { // Point the updater at a dead endpoint (bound then dropped port → // connection refused). The status must carry the error rather than // pretending "up to date". let _ = test_home(); reset_home(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); set_update_base(&format!("http://127.0.0.1:{port}/releases")); set_test_version("0.2.5"); let cfg = make_update_config("stable"); let status = check_update_status(&cfg).await; assert!(!status.update_available); assert_eq!(status.latest_version, None); let err = status.error.as_deref().expect("error must be surfaced"); assert!(!err.is_empty()); // And it serializes into the --json contract. let v = serde_json::to_value(&status).unwrap(); assert!(v["error"].is_string()); assert_eq!(v["updateAvailable"], false); } #[tokio::test] #[serial] async fn check_status_unsupported_channel_reports_error() { let server = ArtifactServer::start(small_good_artifact()); setup(&server, "0.2.7", "0.2.5"); let cfg = make_update_config("beta"); let status = check_update_status(&cfg).await; assert!(!status.update_available); let err = status.error.as_deref().expect("channel error surfaced"); assert!( err.contains("Unsupported release channel 'beta'"), "err: {err}" ); } // ───────────────────────────────────────────────────────────────────────────── // run_update_if_available — the auto-update opt-out gate. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn run_update_if_available_respects_auto_update_false() { // With cli.auto_update = false persisted, the startup check must return // without ever touching the network (the update base points at a dead // port — any fetch would error, any download would install). let _ = test_home(); reset_home(); std::fs::write( test_home().join("config.toml"), "[cli]\nauto_update = false\n", ) .unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let port = listener.local_addr().unwrap().port(); drop(listener); set_update_base(&format!("http://127.0.0.1:{port}/releases")); set_test_version("0.1.0"); let cfg = make_update_config("stable"); let ran = run_update_if_available(UpdateRunMode::Blocking, false, &cfg) .await .unwrap(); assert!(!ran, "auto_update=false must suppress the update entirely"); } // ───────────────────────────────────────────────────────────────────────────── // Race integrity: the accepted same-instant race must stay harmless. Two (or // three) installers running concurrently — even for DIFFERENT versions — // must never leave a corrupt active binary. // ───────────────────────────────────────────────────────────────────────────── async fn run_concurrent_installs( server: &ArtifactServer, versions: &[&str], ) -> Vec> { let base = server.base(); let mut tasks = Vec::new(); for version in versions { let base = base.clone(); let version = version.to_string(); tasks.push(tokio::spawn(async move { let cfg = make_update_config("stable"); install_internal_from_base(Some(&version), &cfg, &base).await })); } let mut results = Vec::new(); for t in tasks { results.push(t.await.expect("install task must not panic")); } results } #[tokio::test(flavor = "multi_thread")] #[serial] async fn concurrent_same_version_installs_leave_valid_active_binary() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let home = test_home(); reset_home(); let platform = host_platform(); let artifact = small_good_artifact(); let server = ArtifactServer::start(artifact.clone()); // Hold responses open so the racers genuinely overlap mid-download. server.set_slow(true); let results = run_concurrent_installs(&server, &["0.1.181", "0.1.181", "0.1.181"]).await; for r in results { r.expect("every racing install must succeed (atomic swap, last writer wins)"); } // Lock-free model: concurrent racers may each download (accepted waste); // the invariant is integrity, not the count. assert!(server.request_count() >= 1); assert_active_binary(home, "0.1.181", &platform, &artifact); } #[tokio::test(flavor = "multi_thread")] #[serial] async fn concurrent_different_version_installs_do_not_corrupt_each_other() { if !can_exec_shell_scripts() { eprintln!("skipping: shell scripts cannot execute in this sandbox"); return; } let home = test_home(); reset_home(); let platform = host_platform(); let artifact = small_good_artifact(); let server = ArtifactServer::start(artifact.clone()); server.set_slow(true); let results = run_concurrent_installs(&server, &["0.1.181", "0.1.182"]).await; for r in results { r.expect("both racing installs must succeed"); } // Both versioned binaries must exist with full, uncorrupted content. for version in ["0.1.181", "0.1.182"] { let path = home .join("downloads") .join(format!("kigi-{version}-{platform}")); assert_eq!( std::fs::read(&path).unwrap(), artifact, "binary {version} must contain exactly the served artifact" ); } // The active symlink points at whichever racer swapped last; it must // resolve and run regardless. let resolved = dunce::canonicalize(home.join("bin").join("kigi")).unwrap(); assert_eq!(std::fs::read(&resolved).unwrap(), artifact); let name = resolved.file_name().unwrap().to_string_lossy().to_string(); assert!( !name.contains(".tmp"), "active kigi must never be a temp file: {name}" ); // No stray shared temp file left behind (a with_extension-style // collision name). assert!( !home.join("downloads").join("kigi-0.1.tmp").exists(), "the shared-temp-name collision must not exist" ); }