use std::process::Command;
use httpmock::prelude::*;
use crate::reliability::harness::binary;
fn releases_body(tag: &str) -> String {
format!(
r#"[{{"tag_name":"{tag}","assets":[
{{"name":"keyhog-linux-x86_64","browser_download_url":"http://example.invalid/lin"}},
{{"name":"keyhog-macos-aarch64","browser_download_url":"http://example.invalid/mac-arm"}},
{{"name":"keyhog-macos-x86_64","browser_download_url":"http://example.invalid/mac-x86"}},
{{"name":"keyhog-windows-x86_64.exe","browser_download_url":"http://example.invalid/win"}}
]}}]"#
)
}
fn run_update(base: &str, extra: &[&str]) -> (Option<i32>, String, String) {
let mut args: Vec<&str> = vec!["update"];
args.extend_from_slice(extra);
let out = Command::new(binary())
.args(&args)
.env("KEYHOG_RELEASE_API_BASE", base)
.output()
.expect("spawn keyhog update");
(
out.status.code(),
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
)
}
fn assert_graceful(code: Option<i32>, stdout: &str, stderr: &str, what: &str) {
assert!(code.is_some(), "{what}: update crashed (killed by signal)");
let hay = format!("{stdout}{stderr}");
for needle in [
"panicked at",
"RUST_BACKTRACE",
"Result::unwrap()",
"Option::unwrap()",
] {
assert!(
!hay.contains(needle),
"{what}: panic marker {needle:?}:\n{hay}"
);
}
assert!(
!stdout.as_bytes().contains(&0x1b) && !stderr.as_bytes().contains(&0x1b),
"{what}: ANSI escape leaked to a pipe"
);
let c = code.unwrap();
assert!(
[0, 2, 3, 10].contains(&c),
"{what}: undocumented exit {c} (update may return 0/10/2/3)\n{hay}"
);
}
fn releases_mock(server: &MockServer, status: u16, body: &str) {
server.mock(|when, then| {
when.method(GET)
.path("/repos/santhsecurity/keyhog/releases");
then.status(status)
.header("content-type", "application/json")
.body(body);
});
}
#[test]
fn newer_release_reports_update_available() {
let server = MockServer::start();
releases_mock(&server, 200, &releases_body("v99.0.0"));
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "newer release");
assert_eq!(
code,
Some(10),
"a strictly newer release must exit 10 (update available)"
);
assert!(
out.to_lowercase().contains("update available") || out.contains("v99.0.0"),
"--check did not announce the available update:\n{out}"
);
}
#[test]
fn current_or_older_release_reports_up_to_date() {
let server = MockServer::start();
releases_mock(&server, 200, &releases_body("v0.0.1"));
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "older release");
assert_eq!(
code,
Some(0),
"an older latest must mean 'already current' (exit 0)"
);
assert!(
out.to_lowercase().contains("latest") || out.to_lowercase().contains("up"),
"did not report up-to-date:\n{out}"
);
}
#[test]
fn malformed_json_fails_gracefully() {
let server = MockServer::start();
releases_mock(&server, 200, "{ this is not valid json at all ][");
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "malformed JSON");
assert_ne!(
code,
Some(10),
"malformed JSON must not be read as 'update available'"
);
assert_ne!(
code,
Some(0),
"malformed JSON must not be read as 'up to date'"
);
}
#[test]
fn truncated_json_fails_gracefully() {
let server = MockServer::start();
releases_mock(&server, 200, "[{\"tag_name\":\"v9.9.9\",\"assets\":[");
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "truncated JSON");
}
#[test]
fn html_error_page_instead_of_json_fails_gracefully() {
let server = MockServer::start();
releases_mock(
&server,
200,
"<!DOCTYPE html><html><body>502 Bad Gateway</body></html>",
);
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "HTML body");
}
#[test]
fn server_500_fails_gracefully() {
let server = MockServer::start();
releases_mock(&server, 500, "internal server error");
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "HTTP 500");
assert_ne!(code, Some(0), "an HTTP 500 must not be treated as success");
assert_ne!(
code,
Some(10),
"an HTTP 500 must not be treated as 'update available'"
);
}
#[test]
fn rate_limit_403_fails_gracefully() {
let server = MockServer::start();
releases_mock(&server, 403, r#"{"message":"API rate limit exceeded"}"#);
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "HTTP 403 rate limit");
}
#[test]
fn empty_release_list_fails_gracefully_with_guidance() {
let server = MockServer::start();
releases_mock(&server, 200, "[]");
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "empty release list");
assert_ne!(code, Some(10));
assert_ne!(code, Some(0), "no releases must not be 'up to date'");
}
#[test]
fn release_with_no_assets_fails_gracefully() {
let server = MockServer::start();
releases_mock(&server, 200, r#"[{"tag_name":"v99.0.0","assets":[]}]"#);
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "asset-less release");
}
#[test]
fn pinned_missing_tag_404_fails_gracefully() {
let server = MockServer::start();
server.mock(|when, then| {
when.method(GET)
.path("/repos/santhsecurity/keyhog/releases/tags/v1.2.3");
then.status(404).body(r#"{"message":"Not Found"}"#);
});
let (code, out, err) = run_update(&server.base_url(), &["--check", "--version", "v1.2.3"]);
assert_graceful(code, &out, &err, "pinned missing tag");
assert_ne!(code, Some(10), "a 404 tag must not be 'update available'");
}
#[test]
fn garbage_tag_name_in_release_does_not_crash() {
let server = MockServer::start();
releases_mock(
&server,
200,
r#"[{"tag_name":"not-a-version-🙃","assets":[{"name":"keyhog-linux-x86_64","browser_download_url":"http://example.invalid/x"}]}]"#,
);
let (code, out, err) = run_update(&server.base_url(), &["--check"]);
assert_graceful(code, &out, &err, "garbage tag");
assert_ne!(
code,
Some(10),
"an unparseable tag must not be 'update available' (fail-safe)"
);
}