omnigraph/crates/omnigraph-cli/tests/update.rs
Devin AI ca92ad34aa fix(cli): address PR review findings on omnigraph update (MR-612)
- Refuse non-interactive update without --yes (Devin Review P1):
  bail with a clear error instead of silently replacing binaries when
  stdin is not a TTY. Matches gh / rustup / apt-get posture.
- Persist 24h cooldown on refresh failures (cubic P2): the hidden
  `__refresh-update-cache` subprocess now writes a fresh
  `checked_at_unix` even when the GitHub API call fails (keeping the
  previously-known tag), so a transient outage doesn't spam refreshes.
- Make the checksum-mismatch test platform-correct (cubic P2): compare
  byte-for-byte against the snapshotted original binary instead of
  ELF/shebang magic, which excluded macOS arm64 (Mach-O).
- Bump cli-reference top-level family count 17 -> 18 (cubic P3).
- Add a new integration test asserting non-interactive bail-out.

Co-Authored-By: Ragnor Comerford <ragnor.comerford@gmail.com>
2026-05-11 23:02:24 +00:00

525 lines
18 KiB
Rust

//! Integration tests for `omnigraph update` (MR-612).
//!
//! Spins up a minimal in-process HTTP server (raw `std::net::TcpListener` so
//! we don't drag axum into the CLI's dev-deps) that serves both:
//! - GitHub Releases JSON metadata (`/repos/.../releases/latest` etc.)
//! - Release asset downloads (`.tar.gz` and `.sha256`)
//!
//! Tests stub the binaries with shell scripts so we can inspect what got
//! written to the install dir without needing real Rust binaries in the
//! release archive.
#![allow(dead_code)]
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader, Write};
use std::net::{TcpListener, TcpStream};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;
use assert_cmd::Command as AssertCommand;
use flate2::Compression;
use flate2::write::GzEncoder;
use sha2::{Digest, Sha256};
use tempfile::TempDir;
mod support;
fn cli_in(install_dir: &Path) -> AssertCommand {
let bin = install_dir.join("omnigraph");
let mut cmd = AssertCommand::new(bin);
// Use a unique cache dir per test so the version_check side never races us.
cmd.env("OMNIGRAPH_NO_UPDATE_CHECK", "1");
cmd
}
/// Stage `omnigraph` and `omnigraph-server` shell stubs into `dir`. Returns the
/// path to the built `omnigraph` shim that calls into `assert_cmd`'s real CLI.
/// We can't put the cargo-built binary into the install dir directly because
/// the update will rename-over it during the test, which would break other
/// tests that share the same target dir.
fn stage_install_dir(dir: &Path, omnigraph_payload: &[u8], server_payload: &[u8]) {
let og = dir.join("omnigraph");
fs::write(&og, omnigraph_payload).unwrap();
let server = dir.join("omnigraph-server");
fs::write(&server, server_payload).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = fs::metadata(&og).unwrap().permissions();
p.set_mode(0o755);
fs::set_permissions(&og, p).unwrap();
let mut p = fs::metadata(&server).unwrap().permissions();
p.set_mode(0o755);
fs::set_permissions(&server, p).unwrap();
}
}
/// Build a `.tar.gz` containing `omnigraph` and (optionally) `omnigraph-server`
/// binaries with the supplied byte payloads. Returns `(archive_bytes, sha256_hex)`.
fn build_release_archive(
omnigraph: &[u8],
omnigraph_server: Option<&[u8]>,
) -> (Vec<u8>, String) {
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
{
let mut tar = tar::Builder::new(&mut gz);
let mut header = tar::Header::new_gnu();
header.set_size(omnigraph.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, "omnigraph", omnigraph).unwrap();
if let Some(server) = omnigraph_server {
let mut h = tar::Header::new_gnu();
h.set_size(server.len() as u64);
h.set_mode(0o755);
h.set_cksum();
tar.append_data(&mut h, "omnigraph-server", server).unwrap();
}
tar.finish().unwrap();
}
let bytes = gz.finish().unwrap();
let mut hasher = Sha256::new();
hasher.update(&bytes);
let digest = format!("{:x}", hasher.finalize());
(bytes, digest)
}
/// In-memory routing table: path -> (status, content-type, body).
type Routes = Arc<Mutex<HashMap<String, (u16, &'static str, Vec<u8>)>>>;
struct Fixture {
addr: String,
_shutdown: TcpListener, // dropped at end of test, ignored
routes: Routes,
_join: Option<thread::JoinHandle<()>>,
}
impl Fixture {
fn start() -> Self {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(false).unwrap();
let addr = format!("http://{}", listener.local_addr().unwrap());
let routes: Routes = Arc::new(Mutex::new(HashMap::new()));
let routes_clone = routes.clone();
let listener_clone = listener.try_clone().unwrap();
let join = thread::spawn(move || {
// Each test spins up a short-lived server. Accept loop runs until
// the listener is dropped (Fixture::stop sets nonblocking + drops).
for stream in listener_clone.incoming() {
let stream = match stream {
Ok(s) => s,
Err(_) => break,
};
let routes = routes_clone.clone();
thread::spawn(move || handle_request(stream, routes));
}
});
Self {
addr,
_shutdown: listener,
routes,
_join: Some(join),
}
}
fn route(&self, path: &str, status: u16, content_type: &'static str, body: Vec<u8>) {
self.routes
.lock()
.unwrap()
.insert(path.to_string(), (status, content_type, body));
}
fn url(&self) -> &str {
&self.addr
}
}
fn handle_request(mut stream: TcpStream, routes: Routes) {
stream.set_read_timeout(Some(Duration::from_secs(5))).ok();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut request_line = String::new();
if reader.read_line(&mut request_line).is_err() {
return;
}
let path = request_line
.split_whitespace()
.nth(1)
.unwrap_or("/")
.to_string();
// Drain headers so the client sees a clean TCP stream.
loop {
let mut line = String::new();
if reader.read_line(&mut line).is_err() {
return;
}
if line == "\r\n" || line.is_empty() {
break;
}
}
let response = match routes.lock().unwrap().get(&path).cloned() {
Some((status, ct, body)) => {
let header = format!(
"HTTP/1.1 {} OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
status,
ct,
body.len()
);
let mut out = header.into_bytes();
out.extend_from_slice(&body);
out
}
None => b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.to_vec(),
};
let _ = stream.write_all(&response);
let _ = stream.flush();
}
/// Build a stable releases-API metadata JSON body.
fn release_json(tag: &str) -> Vec<u8> {
serde_json::to_vec(&serde_json::json!({
"tag_name": tag,
"name": format!("Release {tag}"),
"draft": false,
"prerelease": false,
}))
.unwrap()
}
/// Build the full asset path the binary expects under the configured download
/// base, e.g. `/ModernRelay/omnigraph/releases/download/v0.5.0/omnigraph-linux-x86_64.tar.gz`.
fn asset_path(tag: &str, name: &str) -> String {
format!("/ModernRelay/omnigraph/releases/download/{tag}/{name}")
}
fn current_platform_asset() -> Option<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("linux", "x86_64") => Some("omnigraph-linux-x86_64.tar.gz"),
("macos", "aarch64") => Some("omnigraph-macos-arm64.tar.gz"),
_ => None,
}
}
// ---------- helpers used by individual tests ----------
/// Run the cargo-built `omnigraph` from the install dir, using the test
/// fixture as both the API and download base.
fn run_update(install_dir: &Path, fixture: &Fixture, args: &[&str]) -> std::process::Output {
// `assert_cmd::Command` panics on non-zero exit by default; we want raw
// output to inspect failure modes, so go through the standard `Output`.
let bin = install_dir.join("omnigraph");
let mut cmd = std::process::Command::new(bin);
cmd.arg("update")
.args(args)
.env("OMNIGRAPH_NO_UPDATE_CHECK", "1")
.env("OMNIGRAPH_UPDATE_API_BASE", fixture.url())
.env("OMNIGRAPH_UPDATE_DOWNLOAD_BASE", fixture.url());
cmd.output().unwrap()
}
/// Copy the cargo-built `omnigraph` binary into the install dir so we have a
/// real binary to invoke. We can't link against the lib (there isn't one), so
/// each test stages its own copy.
fn install_real_binary(install_dir: &Path) -> PathBuf {
let cargo_bin = assert_cmd::cargo::cargo_bin("omnigraph");
let target = install_dir.join("omnigraph");
fs::copy(&cargo_bin, &target).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = fs::metadata(&target).unwrap().permissions();
p.set_mode(0o755);
fs::set_permissions(&target, p).unwrap();
}
target
}
fn install_fake_server(install_dir: &Path, payload: &[u8]) {
let target = install_dir.join("omnigraph-server");
fs::write(&target, payload).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut p = fs::metadata(&target).unwrap().permissions();
p.set_mode(0o755);
fs::set_permissions(&target, p).unwrap();
}
}
// ---------- tests ----------
#[test]
fn update_check_when_up_to_date_reports_current() {
let asset = match current_platform_asset() {
Some(a) => a,
None => return, // unsupported platform: nothing to test
};
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
let fixture = Fixture::start();
let current = env!("CARGO_PKG_VERSION");
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json(&format!("v{current}")),
);
let _ = asset; // route only needed when an update is required
let out = run_update(dir.path(), &fixture, &["--check"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
out.status.success(),
"expected success; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
stdout.contains("up to date"),
"expected 'up to date' message, got: {stdout}"
);
}
#[test]
fn update_check_when_newer_version_available_reports_upgrade() {
if current_platform_asset().is_none() {
return;
}
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
let fixture = Fixture::start();
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json("v999.0.0"),
);
let out = run_update(dir.path(), &fixture, &["--check"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("v999.0.0") || stdout.contains("999.0.0"),
"expected new version in output; got: {stdout}"
);
assert!(
stdout.contains("new version") || stdout.contains("newer"),
"expected upgrade-available message; got: {stdout}"
);
}
#[test]
fn update_full_flow_replaces_both_binaries_and_verifies_checksum() {
let asset = match current_platform_asset() {
Some(a) => a,
None => return,
};
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
install_fake_server(dir.path(), b"#!/bin/sh\necho old-server\n");
let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD";
let new_server = b"NEW-SERVER-PAYLOAD";
let (archive, digest) = build_release_archive(new_omnigraph, Some(new_server));
let fixture = Fixture::start();
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json("v999.0.0"),
);
fixture.route(
&asset_path("v999.0.0", asset),
200,
"application/octet-stream",
archive,
);
let stem = asset.trim_end_matches(".tar.gz");
fixture.route(
&asset_path("v999.0.0", &format!("{stem}.sha256")),
200,
"text/plain",
format!("{digest} {asset}\n").into_bytes(),
);
let out = run_update(dir.path(), &fixture, &["--yes"]);
assert!(
out.status.success(),
"update failed; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
// Verify both binaries got replaced byte-for-byte.
let updated_og = fs::read(dir.path().join("omnigraph")).unwrap();
assert_eq!(updated_og, new_omnigraph);
let updated_server = fs::read(dir.path().join("omnigraph-server")).unwrap();
assert_eq!(updated_server, new_server);
}
#[test]
fn update_fails_loudly_on_checksum_mismatch() {
let asset = match current_platform_asset() {
Some(a) => a,
None => return,
};
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
install_fake_server(dir.path(), b"#!/bin/sh\necho old-server\n");
// Snapshot the original bytes so we can assert byte-for-byte preservation
// after a failed update, without making OS-specific assumptions about the
// executable format (ELF on Linux, Mach-O on macOS, etc.).
let original_omnigraph = fs::read(dir.path().join("omnigraph")).unwrap();
let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD";
let (archive, _digest) = build_release_archive(new_omnigraph, None);
let fixture = Fixture::start();
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json("v999.0.0"),
);
fixture.route(
&asset_path("v999.0.0", asset),
200,
"application/octet-stream",
archive,
);
let stem = asset.trim_end_matches(".tar.gz");
let bad_digest = "0".repeat(64);
fixture.route(
&asset_path("v999.0.0", &format!("{stem}.sha256")),
200,
"text/plain",
format!("{bad_digest} {asset}\n").into_bytes(),
);
let out = run_update(dir.path(), &fixture, &["--yes"]);
assert!(!out.status.success(), "checksum mismatch should fail");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("sha256 mismatch") || stderr.contains("checksum"),
"expected checksum error; got: {stderr}"
);
// The original binary must not be replaced when the checksum fails.
let preserved = fs::read(dir.path().join("omnigraph")).unwrap();
assert_eq!(
preserved, original_omnigraph,
"original binary should be preserved byte-for-byte when the checksum fails"
);
assert_ne!(
preserved.as_slice(),
&new_omnigraph[..],
"rejected payload must not have been written"
);
}
#[test]
fn update_does_not_replace_omnigraph_server_when_not_present() {
let asset = match current_platform_asset() {
Some(a) => a,
None => return,
};
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
// Note: no `omnigraph-server` staged.
let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD";
let new_server = b"NEW-SERVER-PAYLOAD";
let (archive, digest) = build_release_archive(new_omnigraph, Some(new_server));
let fixture = Fixture::start();
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json("v999.0.0"),
);
fixture.route(
&asset_path("v999.0.0", asset),
200,
"application/octet-stream",
archive,
);
let stem = asset.trim_end_matches(".tar.gz");
fixture.route(
&asset_path("v999.0.0", &format!("{stem}.sha256")),
200,
"text/plain",
format!("{digest} {asset}\n").into_bytes(),
);
let out = run_update(dir.path(), &fixture, &["--yes"]);
assert!(out.status.success(), "stderr: {}", String::from_utf8_lossy(&out.stderr));
assert!(!dir.path().join("omnigraph-server").exists());
}
#[test]
fn update_refuses_to_run_non_interactively_without_yes() {
let asset = match current_platform_asset() {
Some(a) => a,
None => return,
};
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
let original_omnigraph = fs::read(dir.path().join("omnigraph")).unwrap();
let new_omnigraph = b"NEW-OMNIGRAPH-PAYLOAD";
let (archive, digest) = build_release_archive(new_omnigraph, None);
let fixture = Fixture::start();
fixture.route(
"/repos/ModernRelay/omnigraph/releases/latest",
200,
"application/json",
release_json("v999.0.0"),
);
fixture.route(
&asset_path("v999.0.0", asset),
200,
"application/octet-stream",
archive,
);
let stem = asset.trim_end_matches(".tar.gz");
fixture.route(
&asset_path("v999.0.0", &format!("{stem}.sha256")),
200,
"text/plain",
format!("{digest} {asset}\n").into_bytes(),
);
// No --yes, no TTY (assert_cmd's stdin is not a terminal): must bail out
// before any binary is replaced.
let out = run_update(dir.path(), &fixture, &[]);
assert!(!out.status.success(), "should refuse non-interactive update without --yes");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("non-interactive") || stderr.contains("--yes"),
"expected non-interactive refusal; got: {stderr}"
);
let preserved = fs::read(dir.path().join("omnigraph")).unwrap();
assert_eq!(preserved, original_omnigraph);
}
#[test]
fn update_check_handles_missing_release_metadata() {
if current_platform_asset().is_none() {
return;
}
let dir = TempDir::new().unwrap();
install_real_binary(dir.path());
let fixture = Fixture::start(); // no routes registered
let out = run_update(dir.path(), &fixture, &["--check"]);
assert!(!out.status.success(), "should fail when API returns 404");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("404") || stderr.contains("release lookup failed"),
"expected fetch-error message; got: {stderr}"
);
}