//! End-to-end tests for the internal installer — GitHub Releases via a //! wiremock server shaped like the real API (PRD F8). //! //! Wires together a mocked `releases/…` API + an isolated `KIGI_SHARE_DIR` //! tempdir so we can verify the full install pipeline: //! resolve release → download archive → verify SHA-256 → extract → //! smoke-test → atomic symlink → cleanup_old_downloads → persist config. //! //! The function reads `kigi_home()` (a process-wide `OnceLock`), so all //! tests in this binary share a single `KIGI_SHARE_DIR` 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::{ archive_name, host_platform, make_release_archive, make_update_config, mount_latest, mount_release, release_json, reset_home, sha256_hex, small_good_artifact, test_home, }; use kigi_update::auto_update::install_internal_from_base; use kigi_update::version::installed_on_disk_version; /// Base URL for the updater: `{server}/releases`, mirroring the production /// `https://api.github.com/repos/{owner}/{repo}/releases`. fn base(server: &MockServer) -> String { format!("{}/releases", server.uri()) } // ───────────────────────────────────────────────────────────────────────────── // Happy-path // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_pinned_version_writes_binary_and_symlink() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let home = test_home(); let downloaded = home .join("downloads") .join(format!("kigi-0.1.181-{platform}")); assert!(downloaded.exists(), "binary extracted: {downloaded:?}"); assert_eq!(std::fs::read(&downloaded).unwrap(), small_good_artifact()); let symlink = home.join("bin").join("kigi"); assert!(symlink.is_symlink(), "kigi symlink created"); let target = std::fs::read_link(&symlink).unwrap(); assert_eq!( target.file_name().unwrap(), format!("kigi-0.1.181-{platform}").as_str() ); // The archive must not linger after extraction. assert!( !home .join("downloads") .join(archive_name("0.1.181")) .exists(), "archive should be removed after extraction" ); // The disk-version probe reads the new install back. assert_eq!(installed_on_disk_version().as_deref(), Some("0.1.181")); } #[tokio::test] #[serial] async fn install_chmods_binary_executable() { use std::os::unix::fs::PermissionsExt; let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let binary = test_home() .join("downloads") .join(format!("kigi-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_persists_installer_config() { let _ = test_home(); reset_home(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let cfg_body = std::fs::read_to_string(test_home().join("config.toml")).unwrap(); assert!( cfg_body.contains("installer = \"internal\""), "config should set installer = internal: {cfg_body}" ); } #[tokio::test] #[serial] async fn install_resolves_version_via_latest_when_no_target() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; mount_latest(&server, "0.1.181").await; let cfg = make_update_config("stable"); // No pinned version → must resolve GET {base}/latest. install_internal_from_base(None, &cfg, &base(&server)) .await .unwrap(); assert!( test_home() .join("downloads") .join(format!("kigi-0.1.181-{platform}")) .exists(), "binary at version from /releases/latest" ); } #[tokio::test] #[serial] async fn install_alpha_channel_resolves_semver_max_from_release_list() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; // List endpoint (newest-published first) carries a pre-release AND a // semver-higher stable — the alpha channel must pick the stable (the // max), never get stuck on the pre-release. let list = serde_json::json!([ release_json(&server.uri(), "0.1.180-alpha.5"), release_json(&server.uri(), "0.1.181"), ]); Mock::given(method("GET")) .and(path("/releases")) .respond_with(ResponseTemplate::new(200).set_body_json(list)) .mount(&server) .await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("alpha"); install_internal_from_base(None, &cfg, &base(&server)) .await .unwrap(); assert!( test_home() .join("downloads") .join(format!("kigi-0.1.181-{platform}")) .exists() ); } #[tokio::test] #[serial] async fn install_removes_legacy_links() { // Pre-rewrite installs left `agent` links in bin/; the installer must // retire them. let _ = test_home(); reset_home(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); let bin_dir = test_home().join("bin"); std::fs::create_dir_all(&bin_dir).unwrap(); let legacy = "agent"; std::os::unix::fs::symlink("/tmp/fake-old-target", bin_dir.join(legacy)).unwrap(); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let link = bin_dir.join(legacy); assert!( !link.exists() && !link.is_symlink(), "legacy {legacy} link should be removed" ); assert!(bin_dir.join("kigi").is_symlink(), "kigi link installed"); } // ───────────────────────────────────────────────────────────────────────────── // Failure paths // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_fails_on_archive_404() { let _ = test_home(); reset_home(); let server = MockServer::start().await; // Release JSON + SHA256SUMS exist, but the archive itself 404s. let archive = make_release_archive(&small_good_artifact()); Mock::given(method("GET")) .and(path("/releases/tags/v0.1.181")) .respond_with( ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), "0.1.181")), ) .mount(&server) .await; Mock::given(method("GET")) .and(path("/dl/v0.1.181/SHA256SUMS")) .respond_with(ResponseTemplate::new(200).set_body_string(format!( "{} {}\n", sha256_hex(&archive), archive_name("0.1.181") ))) .mount(&server) .await; Mock::given(method("GET")) .and(path(format!("/dl/v0.1.181/{}", archive_name("0.1.181")))) .respond_with(ResponseTemplate::new(404)) .mount(&server) .await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("Download failed"), "msg: {msg}"); } #[tokio::test] #[serial] async fn install_fails_on_missing_release() { let _ = test_home(); reset_home(); let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/releases/tags/v0.9.9")) .respond_with(ResponseTemplate::new(404).set_body_string(r#"{"message":"Not Found"}"#)) .mount(&server) .await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("0.9.9"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("404"), "msg: {msg}"); } #[tokio::test] #[serial] async fn install_rejects_invalid_pinned_version() { let _ = test_home(); reset_home(); let server = MockServer::start().await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("not-a-version"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("invalid version format"), "msg: {msg}"); } #[tokio::test] #[serial] async fn install_rejects_checksum_mismatch_and_leaves_no_binary() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; // SHA256SUMS lists a hash that does NOT match the served archive. Mock::given(method("GET")) .and(path("/releases/tags/v0.1.181")) .respond_with( ResponseTemplate::new(200).set_body_json(release_json(&server.uri(), "0.1.181")), ) .mount(&server) .await; Mock::given(method("GET")) .and(path("/dl/v0.1.181/SHA256SUMS")) .respond_with(ResponseTemplate::new(200).set_body_string(format!( "{} {}\n", "0".repeat(64), archive_name("0.1.181") ))) .mount(&server) .await; Mock::given(method("GET")) .and(path(format!("/dl/v0.1.181/{}", archive_name("0.1.181")))) .respond_with( ResponseTemplate::new(200).set_body_bytes(make_release_archive(&small_good_artifact())), ) .mount(&server) .await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("SHA256 mismatch"), "msg: {msg}"); let home = test_home(); assert!( !home .join("downloads") .join(format!("kigi-0.1.181-{platform}")) .exists(), "no binary may be published from a checksum-failed archive" ); assert!( !home .join("downloads") .join(archive_name("0.1.181")) .exists(), "the rejected archive must be deleted" ); assert!( !home.join("bin").join("kigi").is_symlink(), "no symlink may be activated" ); } #[tokio::test] #[serial] async fn install_fails_when_sha256sums_asset_missing() { let _ = test_home(); reset_home(); let server = MockServer::start().await; // Release JSON without a SHA256SUMS asset — must fail up front, before // downloading the archive. let name = archive_name("0.1.181"); let json = serde_json::json!({ "tag_name": "v0.1.181", "draft": false, "prerelease": false, "assets": [ { "name": name, "browser_download_url": format!("{}/dl/v0.1.181/{name}", server.uri()) }, ], }); Mock::given(method("GET")) .and(path("/releases/tags/v0.1.181")) .respond_with(ResponseTemplate::new(200).set_body_json(json)) .mount(&server) .await; // The archive endpoint must never be contacted. Mock::given(method("GET")) .and(path(format!("/dl/v0.1.181/{name}"))) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&server) .await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("SHA256SUMS"), "msg: {msg}"); } #[tokio::test] #[serial] async fn install_smoke_test_rejects_bad_binary_with_valid_checksum() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; // Archive checksums fine but its binary exits 1 — only the smoke test // can catch this, and it must leave no active install behind. mount_release(&server, "0.1.181", b"#!/bin/sh\nexit 1\n").await; let cfg = make_update_config("stable"); let err = install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap_err(); let msg = format!("{err:#}"); assert!(msg.contains("failed to run"), "msg: {msg}"); let home = test_home(); assert!( !home .join("downloads") .join(format!("kigi-0.1.181-{platform}")) .exists(), "smoke-test-failed binary must be deleted" ); assert!( !home.join("bin").join("kigi").is_symlink(), "no symlink may be activated" ); } #[tokio::test] #[serial] async fn install_swap_failure_leaves_prior_install_active() { // Sabotage activation: bin/kigi as a non-empty directory makes the // symlink rename fail. The download itself lands, but the prior state // of bin/ must be untouched (nothing half-activated). let _ = test_home(); reset_home(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); let bin_dir = test_home().join("bin"); let kigi_dir = bin_dir.join("kigi"); std::fs::create_dir_all(&kigi_dir).unwrap(); std::fs::write(kigi_dir.join("blocker"), b"x").unwrap(); let err = install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .expect_err("swap must fail when the link path is a non-empty dir"); let msg = format!("{err:#}"); assert!(msg.contains("swapping managed bin link"), "msg: {msg}"); assert!( kigi_dir.is_dir() && kigi_dir.join("blocker").exists(), "failed swap must not clobber the existing path" ); } // ───────────────────────────────────────────────────────────────────────────── // Rollback semantics: pinned installs move DOWN as well as up (the release // channel is authoritative for the internal installer). // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_rollback_then_upgrade_sequence() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; for v in ["0.2.5", "0.2.7"] { mount_release(&server, v, &small_good_artifact()).await; } let cfg = make_update_config("stable"); // Up, back down (rollback), then up again. for version in ["0.2.7", "0.2.5", "0.2.7"] { install_internal_from_base(Some(version), &cfg, &base(&server)) .await .unwrap(); let target = std::fs::read_link(test_home().join("bin").join("kigi")).unwrap(); assert_eq!( target.file_name().unwrap(), format!("kigi-{version}-{platform}").as_str(), "active binary must follow the pinned install" ); assert_eq!(installed_on_disk_version().as_deref(), Some(version)); } } // ───────────────────────────────────────────────────────────────────────────── // Cleanup integration: install v1..v3, verify N-1 retention. // ───────────────────────────────────────────────────────────────────────────── #[tokio::test] #[serial] async fn install_cleans_up_old_versions_keeping_n_minus_one() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; for v in ["0.1.179", "0.1.180", "0.1.181"] { mount_release(&server, v, &small_good_artifact()).await; } let cfg = make_update_config("stable"); 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(); install_internal_from_base(Some(v), &cfg, &base(&server)) .await .unwrap(); } let downloads = test_home().join("downloads"); assert!( downloads.join(format!("kigi-0.1.181-{platform}")).exists(), "current" ); assert!( downloads.join(format!("kigi-0.1.180-{platform}")).exists(), "N-1 retained" ); assert!( !downloads.join(format!("kigi-0.1.179-{platform}")).exists(), "oldest deleted" ); let target = std::fs::read_link(test_home().join("bin").join("kigi")).unwrap(); assert!( target .file_name() .unwrap() .to_string_lossy() .contains("0.1.181"), "symlink points to latest: {target:?}" ); } #[tokio::test] #[serial] async fn install_idempotent_for_same_version() { let _ = test_home(); reset_home(); let platform = host_platform(); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let path_installed = test_home() .join("downloads") .join(format!("kigi-0.1.181-{platform}")); let first = std::fs::read(&path_installed).unwrap(); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); let second = std::fs::read(&path_installed).unwrap(); assert_eq!(first, second); let target = std::fs::read_link(test_home().join("bin").join("kigi")).unwrap(); assert!(target.to_string_lossy().contains("0.1.181")); } #[tokio::test] #[serial] async fn install_creates_kigi_home_subdirs_if_missing() { let _ = test_home(); reset_home(); let _ = std::fs::remove_dir_all(test_home().join("bin")); let _ = std::fs::remove_dir_all(test_home().join("downloads")); let server = MockServer::start().await; mount_release(&server, "0.1.181", &small_good_artifact()).await; let cfg = make_update_config("stable"); install_internal_from_base(Some("0.1.181"), &cfg, &base(&server)) .await .unwrap(); assert!(test_home().join("bin").is_dir()); assert!(test_home().join("downloads").is_dir()); }