//! End-to-end tests for `install_internal` — the GCS-bucket installer used //! when `installer = "internal"` is configured. //! //! Wires together a wiremock-mocked GCS bucket + an isolated `GROK_HOME` //! tempdir so we can verify the full install pipeline: //! fetch version → download grok binary → chmod → atomic symlink → //! cleanup_old_downloads → persist installer config. //! //! The function reads `grok_home()` (a process-wide `OnceLock`), so all //! tests in this binary share a single `GROK_HOME` and run serially via //! `#[serial]`. #![cfg(unix)] mod common; use serial_test::serial; use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; use common::{reset_home, test_home}; use xai_grok_update::UpdateConfig; use xai_grok_update::auto_update::{install_internal_from_base, install_internal_from_bases}; fn host_platform() -> String { let os = if cfg!(target_os = "macos") { "macos" } else if cfg!(target_os = "linux") { "linux" } else { panic!("unsupported test platform"); }; let arch = if cfg!(target_arch = "x86_64") { "x86_64" } else if cfg!(target_arch = "aarch64") { "aarch64" } else { panic!("unsupported test arch"); }; format!("{os}-{arch}") } fn make_config(channel: &str) -> UpdateConfig { UpdateConfig { proxy_base_url: "http://test.invalid/v1".to_string(), auth_scope: "test".to_string(), deployment_key: None, alpha_test_key: None, channel: channel.to_string(), npm_registry: None, } } /// Mount GCS endpoints for a given version. Returns the `MockServer`. async fn mount_gcs(version: &str, platform: &str) -> MockServer { let server = MockServer::start().await; // Channel pointer: stable returns this version. Mock::given(method("GET")) .and(path("/stable")) .respond_with(ResponseTemplate::new(200).set_body_string(version)) .mount(&server) .await; // Main grok binary download. Mock::given(method("GET")) .and(path(format!("/grok-{version}-{platform}"))) .respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec())) .mount(&server) .await; server } // ───────────────────────────────────────────────────────────────────────────── // Happy-path // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_internal_pinned_version_writes_binary_and_symlink() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let home = test_home(); let downloaded = home .join("downloads") .join(format!("grok-0.1.181-{platform}")); assert!(downloaded.exists(), "binary downloaded: {downloaded:?}"); assert_eq!(std::fs::read(&downloaded).unwrap(), b"#!/bin/sh\nexit 0\n"); let symlink = home.join("bin").join("grok"); assert!(symlink.is_symlink(), "grok symlink created"); let target = std::fs::read_link(&symlink).unwrap(); assert_eq!( target.file_name().unwrap(), format!("grok-0.1.181-{platform}").as_str() ); // `grok` and `agent` move together — see `swap_managed_bin_links`. let agent_link = home.join("bin").join("agent"); assert!(agent_link.is_symlink(), "agent symlink created"); let agent_target = std::fs::read_link(&agent_link).unwrap(); assert_eq!(agent_target, target, "agent and grok point at same target"); } /// Regression: pre-existing `agent` symlink from a prior install must be /// swapped to the new version, not left stale (the original bug). #[tokio::test] #[serial] async fn install_internal_updates_stale_agent_symlink_to_new_version() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); // Prior install: both links point at an older versioned binary. let home = test_home(); let bin_dir = home.join("bin"); let download_dir = home.join("downloads"); std::fs::create_dir_all(&bin_dir).unwrap(); std::fs::create_dir_all(&download_dir).unwrap(); let old_binary = download_dir.join(format!("grok-0.1.180-{platform}")); std::fs::write(&old_binary, b"#!/bin/sh\nexit 0\n").unwrap(); let rel_old = std::path::Path::new("..") .join("downloads") .join(format!("grok-0.1.180-{platform}")); std::os::unix::fs::symlink(&rel_old, bin_dir.join("grok")).unwrap(); std::os::unix::fs::symlink(&rel_old, bin_dir.join("agent")).unwrap(); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let agent_link = bin_dir.join("agent"); let agent_target = std::fs::read_link(&agent_link).unwrap(); assert_eq!( agent_target.file_name().unwrap(), format!("grok-0.1.181-{platform}").as_str(), "agent symlink must swap to the new version, not stay on old" ); } /// Rollback regression: if `agent` swap fails after `grok` succeeded, /// `grok` must roll back to its prior target (all-or-nothing). #[tokio::test] #[serial] async fn install_internal_rolls_back_grok_when_agent_swap_fails() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); let home = test_home(); let bin_dir = home.join("bin"); let download_dir = home.join("downloads"); std::fs::create_dir_all(&bin_dir).unwrap(); std::fs::create_dir_all(&download_dir).unwrap(); let old_binary = download_dir.join(format!("grok-0.1.180-{platform}")); std::fs::write(&old_binary, b"#!/bin/sh\nexit 0\n").unwrap(); let rel_old = std::path::Path::new("..") .join("downloads") .join(format!("grok-0.1.180-{platform}")); std::os::unix::fs::symlink(&rel_old, bin_dir.join("grok")).unwrap(); // Sabotage the agent swap: non-empty directory → rename fails with EISDIR. let agent_dir = bin_dir.join("agent"); std::fs::create_dir(&agent_dir).unwrap(); std::fs::write(agent_dir.join("blocker"), b"x").unwrap(); let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .expect_err("agent swap must fail when target is a non-empty dir"); drop(err); // grok must be rolled back to the prior version. let grok_target = std::fs::read_link(bin_dir.join("grok")).unwrap(); assert_eq!( grok_target.file_name().unwrap(), format!("grok-0.1.180-{platform}").as_str(), "grok must be rolled back when agent swap fails" ); } /// Absent-prior rollback regression: fresh install (no prior `grok` / /// `agent`), sabotaged `agent` swap must *remove* the just-created `grok` /// link so we don't leave it on the new binary while `agent` is absent. #[tokio::test] #[serial] async fn install_internal_rollback_removes_absent_prior_grok_link() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); let home = test_home(); let bin_dir = home.join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); // No prior `grok`. Sabotage `agent` swap: non-empty directory → EISDIR. let agent_dir = bin_dir.join("agent"); std::fs::create_dir(&agent_dir).unwrap(); std::fs::write(agent_dir.join("blocker"), b"x").unwrap(); assert!( !bin_dir.join("grok").exists() && !bin_dir.join("grok").is_symlink(), "precondition: grok must not exist before install", ); let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .expect_err("agent swap must fail when target is a non-empty dir"); drop(err); let grok_path = bin_dir.join("grok"); assert!( !grok_path.is_symlink() && !grok_path.exists(), "grok must be removed on rollback when there was no prior link", ); } #[tokio::test] #[serial] async fn install_internal_chmods_binary_executable() { use std::os::unix::fs::PermissionsExt; let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let home = test_home(); let binary = home .join("downloads") .join(format!("grok-0.1.181-{platform}")); let mode = std::fs::metadata(&binary).unwrap().permissions().mode(); assert!(mode & 0o111 != 0, "binary must be executable, got {mode:o}"); } #[tokio::test] #[serial] async fn install_internal_cleans_up_stale_pager_symlink() { // Old installations shipped a separate grok-pager binary. Verify the // update removes the stale symlink from ~/.grok/bin/. let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); let home = test_home(); let bin_dir = home.join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); let pager_link = bin_dir.join("grok-pager"); std::os::unix::fs::symlink("/tmp/fake-old-pager", &pager_link).unwrap(); assert!( pager_link.is_symlink(), "precondition: stale symlink exists" ); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); assert!( !pager_link.exists() && !pager_link.is_symlink(), "stale grok-pager symlink should be removed" ); } #[tokio::test] #[serial] async fn install_internal_persists_installer_config() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let home = test_home(); let cfg_body = std::fs::read_to_string(home.join("config.toml")).unwrap(); assert!( cfg_body.contains("installer = \"internal\""), "config should set installer = internal: {cfg_body}" ); } #[tokio::test] #[serial] async fn install_internal_resolves_version_via_channel_pointer_when_no_target() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); // No pinned version → must fetch /stable pointer to resolve. install_internal_from_base(None, &cfg, &server.uri()) .await .unwrap(); let home = test_home(); assert!( home.join("downloads") .join(format!("grok-0.1.181-{platform}")) .exists(), "binary at version from /stable pointer" ); } #[tokio::test] #[serial] async fn install_internal_alpha_channel_resolves_max_of_alpha_and_stable() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; // Stable points to 0.1.181, alpha points to 0.1.180-alpha.5 — stable wins. Mock::given(method("GET")) .and(path("/stable")) .respond_with(ResponseTemplate::new(200).set_body_string("0.1.181")) .mount(&server) .await; Mock::given(method("GET")) .and(path("/alpha")) .respond_with(ResponseTemplate::new(200).set_body_string("0.1.180-alpha.5")) .mount(&server) .await; Mock::given(method("GET")) .and(path(format!("/grok-0.1.181-{platform}"))) .respond_with(ResponseTemplate::new(200).set_body_bytes(b"#!/bin/sh\nexit 0\n".to_vec())) .mount(&server) .await; let cfg = make_config("alpha"); install_internal_from_base(None, &cfg, &server.uri()) .await .unwrap(); let home = test_home(); assert!( home.join("downloads") .join(format!("grok-0.1.181-{platform}")) .exists() ); } // ───────────────────────────────────────────────────────────────────────────── // Failure paths // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_internal_fails_on_grok_binary_404() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/stable")) .respond_with(ResponseTemplate::new(200).set_body_string("0.1.181")) .mount(&server) .await; // Main binary returns 404 — must propagate as error. Mock::given(method("GET")) .and(path(format!("/grok-0.1.181-{platform}"))) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let cfg = make_config("stable"); let err = install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Download failed"), "msg: {msg}"); } #[tokio::test] #[serial] async fn install_internal_rejects_invalid_pinned_version() { let _ = test_home(); reset_home(); let server = MockServer::start().await; let cfg = make_config("stable"); let err = install_internal_from_base(Some("not-a-version"), &cfg, &server.uri()) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("invalid version format"), "msg: {msg}"); } // ───────────────────────────────────────────────────────────────────────────── // Cleanup integration: install v1, then v2, verify N-1 retention. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_internal_cleans_up_old_versions_keeping_n_minus_one() { let _ = test_home(); reset_home(); let platform = host_platform(); // Install v1, v2, v3 sequentially. After v3, only v3 (current) and v2 // (N-1) should remain on disk; v1 should be deleted. for v in ["0.1.179", "0.1.180", "0.1.181"] { // Age earlier installs: cleanup never deletes freshly-written // binaries (concurrent-racer protection), so retention assertions // need the previous installs to look like old leftovers. common::backdate_downloads(); let server = mount_gcs(v, &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some(v), &cfg, &server.uri()) .await .unwrap(); } let home = test_home(); let downloads = home.join("downloads"); assert!( downloads.join(format!("grok-0.1.181-{platform}")).exists(), "current" ); assert!( downloads.join(format!("grok-0.1.180-{platform}")).exists(), "N-1 retained" ); assert!( !downloads.join(format!("grok-0.1.179-{platform}")).exists(), "oldest deleted" ); // Symlink updated to latest. let target = std::fs::read_link(home.join("bin").join("grok")).unwrap(); assert!( target .file_name() .unwrap() .to_string_lossy() .contains("0.1.181"), "symlink points to latest: {target:?}" ); } #[tokio::test] #[serial] async fn install_internal_idempotent_for_same_version() { // Re-installing the same version should not error and should leave the // binary at the same path with the same content. let _ = test_home(); reset_home(); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let first = std::fs::read( test_home() .join("downloads") .join(format!("grok-0.1.181-{platform}")), ) .unwrap(); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); let second = std::fs::read( test_home() .join("downloads") .join(format!("grok-0.1.181-{platform}")), ) .unwrap(); assert_eq!(first, second); let target = std::fs::read_link(test_home().join("bin").join("grok")).unwrap(); assert!(target.to_string_lossy().contains("0.1.181")); } #[tokio::test] #[serial] async fn install_internal_creates_grok_home_subdirs_if_missing() { let _ = test_home(); reset_home(); // Explicitly delete bin/ and downloads/ so install must create them. let _ = std::fs::remove_dir_all(test_home().join("bin")); let _ = std::fs::remove_dir_all(test_home().join("downloads")); let platform = host_platform(); let server = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &server.uri()) .await .unwrap(); assert!(test_home().join("bin").is_dir()); assert!(test_home().join("downloads").is_dir()); } // ───────────────────────────────────────────────────────────────────────────── // Multi-base URL fallback: install_internal_from_bases tries each base in // preference order, falling through to the next on failure. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_internal_from_bases_falls_back_to_secondary_when_primary_fails() { // Primary server returns 500 on every endpoint (CDN outage simulation); // fallback server serves the install successfully. Result: install // succeeds via fallback. let _ = test_home(); reset_home(); let platform = host_platform(); let primary = MockServer::start().await; Mock::given(method("GET")) .respond_with(ResponseTemplate::new(500)) .mount(&primary) .await; let fallback = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_bases( Some("0.1.181"), &cfg, &[primary.uri().as_str(), fallback.uri().as_str()], ) .await .unwrap(); assert!( test_home() .join("downloads") .join(format!("grok-0.1.181-{platform}")) .exists(), "fallback should produce a downloaded binary" ); } #[tokio::test] #[serial] async fn install_internal_from_bases_uses_primary_when_it_works() { // Both bases work; the install must use the primary (first one) and // never touch the fallback. Verified by tearing down the fallback // server immediately after configuration — if the install reached for // it, the request would fail. let _ = test_home(); reset_home(); let platform = host_platform(); let primary = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); install_internal_from_bases( Some("0.1.181"), &cfg, &[primary.uri().as_str(), "http://127.0.0.1:1"], ) .await .unwrap(); assert!( test_home() .join("downloads") .join(format!("grok-0.1.181-{platform}")) .exists() ); } #[tokio::test] #[serial] async fn install_internal_from_bases_propagates_last_error_when_all_fail() { // Every base returns 500 — the install must fail, surfacing the final // base's error rather than silently succeeding. let _ = test_home(); reset_home(); let bad1 = MockServer::start().await; Mock::given(method("GET")) .respond_with(ResponseTemplate::new(500)) .mount(&bad1) .await; let bad2 = MockServer::start().await; Mock::given(method("GET")) .respond_with(ResponseTemplate::new(500)) .mount(&bad2) .await; let cfg = make_config("stable"); let err = install_internal_from_bases( Some("0.1.181"), &cfg, &[bad1.uri().as_str(), bad2.uri().as_str()], ) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Download failed"), "msg: {msg}"); } /// Regression: a local failure after a successful download (sabotaged /// `agent` swap) must fail the install immediately — the fallback base must /// never be contacted for a pointless re-download. #[tokio::test] #[serial] async fn install_internal_from_bases_does_not_redownload_on_local_swap_failure() { let _ = test_home(); reset_home(); let platform = host_platform(); let primary = mount_gcs("0.1.181", &platform).await; let fallback = mount_gcs("0.1.181", &platform).await; let cfg = make_config("stable"); let home = test_home(); let bin_dir = home.join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); // Sabotage activation: agent as a non-empty dir fails the swap's // rollback capture (read_link on a directory) before any rename. let agent_dir = bin_dir.join("agent"); std::fs::create_dir(&agent_dir).unwrap(); std::fs::write(agent_dir.join("blocker"), b"x").unwrap(); install_internal_from_bases( Some("0.1.181"), &cfg, &[primary.uri().as_str(), fallback.uri().as_str()], ) .await .expect_err("swap failure must fail the install"); let fallback_requests = fallback .received_requests() .await .expect("request recording is enabled on MockServer::start()"); assert!( fallback_requests.is_empty(), "local swap failure must not fall through to the next base: {} request(s)", fallback_requests.len() ); }