use super::*;
use axum::{response::Redirect, routing::get, Router};
use minisign::KeyPair;
use std::collections::BTreeMap;
use std::io::Cursor;
use tokio::net::TcpListener;
#[test]
fn normalize_tag_adds_prefix_when_missing() {
assert_eq!(normalize_tag("4.6.2"), "v4.6.2");
}
#[test]
fn normalize_tag_keeps_existing_prefix() {
assert_eq!(normalize_tag("v4.6.2"), "v4.6.2");
}
#[test]
fn parse_checksum_for_asset_finds_plain_filename() {
let checksums =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa cc-switch-tui-linux-x64-musl.tar.gz\n";
let got = parse_checksum_for_asset(checksums, "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("checksum should exist");
assert_eq!(
got,
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
);
}
#[test]
fn parse_checksum_for_asset_supports_star_prefix() {
let checksums =
"BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB *cc-switch-tui-linux-x64-musl.tar.gz\n";
let got = parse_checksum_for_asset(checksums, "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("checksum should exist");
assert_eq!(
got,
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
);
}
#[test]
fn parse_checksum_for_asset_supports_spaces_in_filename() {
let checksums =
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc file with spaces.tar.gz\n";
let got = parse_checksum_for_asset(checksums, "file with spaces.tar.gz")
.expect("checksum should exist");
assert_eq!(
got,
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
);
}
#[test]
fn release_page_url_for_github_com() {
let url = release_page_url("https://github.com/handy-sun/cc-switch-tui", "latest")
.expect("release page url should be built");
assert_eq!(
url.as_str(),
"https://github.com/handy-sun/cc-switch-tui/releases/latest"
);
}
#[test]
fn release_page_url_for_github_enterprise() {
let url = release_page_url(
"https://github.enterprise.local/team/cc-switch-cli.git",
"tag/v4.6.2",
)
.expect("release page url should be built");
assert_eq!(
url.as_str(),
"https://github.enterprise.local/team/cc-switch-cli/releases/tag/v4.6.2"
);
}
#[test]
fn release_asset_names_prefer_plain_then_tagged_variant() {
let names = release_asset_names("v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz");
assert_eq!(
names,
vec![
"cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
"cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
]
);
}
#[test]
fn release_api_url_for_github_com() {
let url = release_api_url("https://github.com/handy-sun/cc-switch-tui", "latest")
.expect("api url should be built");
assert_eq!(
url.as_str(),
"https://api.github.com/repos/handy-sun/cc-switch-tui/releases/latest"
);
}
#[test]
fn extract_release_tag_from_url_reads_release_tag_page() {
let url = Url::parse("https://github.com/handy-sun/cc-switch-tui/releases/tag/v4.6.2")
.expect("url should parse");
let tag = extract_release_tag_from_url(&url).expect("tag should be extracted");
assert_eq!(tag, "v4.6.2");
}
#[tokio::test]
async fn fetch_latest_release_tag_prefers_release_api_when_available() {
let app = Router::new()
.route(
"/api/v3/repos/team/cc-switch-cli/releases/latest",
get(|| async { axum::Json(serde_json::json!({ "tag_name": "v4.6.3" })) }),
)
.route(
"/team/cc-switch-cli/releases/latest",
get(|| async { Redirect::temporary("/team/cc-switch-cli/releases/tag/v4.6.2") }),
)
.route(
"/team/cc-switch-cli/releases/tag/v4.6.2",
get(|| async { "ok" }),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let tag = fetch_latest_release_tag(&client, &repo_url)
.await
.expect("latest tag should resolve from release api");
assert_eq!(tag, "v4.6.3");
server.abort();
}
#[tokio::test]
async fn fetch_latest_release_tag_falls_back_to_release_page_after_rate_limit() {
let app = Router::new()
.route(
"/team/cc-switch-cli/releases/latest",
get(|| async { Redirect::temporary("/team/cc-switch-cli/releases/tag/v4.6.2") }),
)
.route(
"/team/cc-switch-cli/releases/tag/v4.6.2",
get(|| async { "ok" }),
)
.route(
"/api/v3/repos/team/cc-switch-cli/releases/latest",
get(|| async { axum::http::StatusCode::FORBIDDEN }),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let tag = fetch_latest_release_tag(&client, &repo_url)
.await
.expect("latest tag should resolve from redirect");
assert_eq!(tag, "v4.6.2");
server.abort();
}
#[test]
fn select_release_asset_prefers_unprefixed_name() {
let assets = vec![
ReleaseAsset {
name: "cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/tagged".to_string(),
digest: None,
},
ReleaseAsset {
name: "cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/plain".to_string(),
digest: None,
},
];
let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("asset should be selected");
assert_eq!(selected.browser_download_url, "https://example.com/plain");
}
#[test]
fn select_release_asset_falls_back_to_tagged_variant() {
let assets = vec![ReleaseAsset {
name: "cc-switch-tui-v4.6.2-linux-x64-musl.tar.gz".to_string(),
browser_download_url: "https://example.com/tagged".to_string(),
digest: None,
}];
let selected = select_release_asset(&assets, "v4.6.2", "cc-switch-tui-linux-x64-musl.tar.gz")
.expect("asset should be selected");
assert_eq!(selected.browser_download_url, "https://example.com/tagged");
}
#[test]
fn parse_sha256_digest_accepts_valid_value() {
let digest = parse_sha256_digest(
"sha256:ABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCDEFABCD",
)
.expect("digest should parse");
assert_eq!(
digest,
"abcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcdefabcd"
);
}
#[test]
fn should_skip_implicit_downgrade_for_prerelease_current() {
assert!(should_skip_implicit_downgrade(
"4.7.0-alpha.1",
"4.6.2",
false
));
}
#[test]
fn should_not_skip_when_version_explicitly_requested() {
assert!(!should_skip_implicit_downgrade(
"4.7.0-alpha.1",
"4.6.2",
true
));
}
#[test]
fn sanitized_asset_file_name_strips_path_segments() {
let name = sanitized_asset_file_name("nested/path/cc-switch-tui-linux-x64-musl.tar.gz")
.expect("file name should be extracted");
assert_eq!(name, "cc-switch-tui-linux-x64-musl.tar.gz");
}
#[test]
fn sanitized_asset_file_name_rejects_invalid_value() {
let err = sanitized_asset_file_name("").expect_err("empty name should fail");
assert!(err.to_string().contains("Invalid asset name"));
}
#[test]
fn validate_target_tag_accepts_normal_value() {
validate_target_tag("v4.6.3-rc1").expect("valid tag should pass");
}
#[test]
fn validate_target_tag_rejects_path_content() {
let err = validate_target_tag("v4.6.3/../../evil").expect_err("must reject traversal");
assert!(err.to_string().contains("forbidden"));
}
#[test]
fn validate_download_size_limit_accepts_limit_boundary() {
validate_download_size_limit(
MAX_RELEASE_ASSET_SIZE_BYTES,
"cc-switch-tui-linux-x64-musl.tar.gz",
)
.expect("size at limit should pass");
}
#[test]
fn validate_download_size_limit_rejects_oversized_asset() {
let err = validate_download_size_limit(
MAX_RELEASE_ASSET_SIZE_BYTES + 1,
"cc-switch-tui-linux-x64-musl.tar.gz",
)
.expect_err("size over limit should fail");
assert!(err.to_string().contains("too large"));
}
#[test]
fn select_manifest_asset_prefers_linux_glibc_variant_when_overridden() {
let manifest = UpdateManifest {
version: "v4.6.3".to_string(),
notes: None,
pub_date: None,
platforms: BTreeMap::from([(
"linux-x86_64".to_string(),
UpdatePlatformEntry {
url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
variants: BTreeMap::from([(
"glibc".to_string(),
UpdatePlatformVariant {
url: "https://example.com/cc-switch-tui-linux-x64.tar.gz".to_string(),
signature: "glibc-signature".to_string(),
},
)]),
},
)]),
};
let asset = select_manifest_asset(&manifest, "linux-x86_64", LinuxLibcPreference::Glibc)
.expect("glibc variant should be selected");
assert_eq!(
asset.url,
"https://example.com/cc-switch-tui-linux-x64.tar.gz"
);
assert_eq!(asset.signature, "glibc-signature");
}
#[test]
fn select_manifest_asset_accepts_glibc_primary_entry_without_variant() {
let manifest = UpdateManifest {
version: "v4.6.3".to_string(),
notes: None,
pub_date: None,
platforms: BTreeMap::from([(
"linux-x86_64".to_string(),
UpdatePlatformEntry {
url: "https://example.com/glibc.tar.gz".to_string(),
signature: "glibc-signature".to_string(),
variants: BTreeMap::new(),
},
)]),
};
let asset = select_manifest_asset(&manifest, "linux-x86_64", LinuxLibcPreference::Glibc)
.expect("glibc primary entry should be accepted");
assert_eq!(asset.url, "https://example.com/glibc.tar.gz");
}
#[test]
fn manifest_linux_asset_candidates_keep_musl_strict_when_forced() {
let manifest = UpdateManifest {
version: "v4.6.3".to_string(),
notes: None,
pub_date: None,
platforms: BTreeMap::from([(
"linux-x86_64".to_string(),
UpdatePlatformEntry {
url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
variants: BTreeMap::from([(
"glibc".to_string(),
UpdatePlatformVariant {
url: "https://example.com/cc-switch-tui-linux-x64.tar.gz".to_string(),
signature: "glibc-signature".to_string(),
},
)]),
},
)]),
};
let candidates =
manifest_asset_candidates(&manifest, "linux-x86_64", LinuxLibcPreference::Musl)
.expect("musl candidates should resolve");
assert_eq!(
candidates,
vec![ManifestAsset {
url: "https://example.com/cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
signature: "musl-signature".to_string(),
}]
);
}
#[test]
fn legacy_linux_asset_candidates_follow_glibc_override() {
let candidates =
release_asset_candidates_for_platform("linux", "x86_64", LinuxLibcPreference::Glibc)
.expect("glibc candidates should resolve");
assert_eq!(
candidates,
vec![
"cc-switch-tui-linux-x64.tar.gz".to_string(),
"cc-switch-tui-linux-x64-musl.tar.gz".to_string(),
]
);
}
#[test]
fn legacy_linux_asset_candidates_keep_musl_strict_when_forced() {
let candidates =
release_asset_candidates_for_platform("linux", "x86_64", LinuxLibcPreference::Musl)
.expect("musl candidates should resolve");
assert_eq!(
candidates,
vec!["cc-switch-tui-linux-x64-musl.tar.gz".to_string(),]
);
}
#[tokio::test]
async fn fetch_update_manifest_reads_latest_json_without_release_api() {
let platform_key = current_platform_key().expect("platform key should resolve");
let manifest = serde_json::json!({
"version": "v4.6.3",
"notes": "manifest path",
"pub_date": "2026-03-14T00:00:00Z",
"platforms": {
platform_key: {
"url": "https://example.com/cc-switch.tar.gz",
"signature": "fake-signature"
}
}
});
let app = Router::new().route(
"/team/cc-switch-cli/releases/latest/download/latest.json",
get(move || {
let manifest = manifest.clone();
async move { axum::Json(manifest) }
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let manifest = fetch_update_manifest(&client, &repo_url, None)
.await
.expect("latest manifest should resolve");
assert_eq!(manifest.version, "v4.6.3");
server.abort();
}
#[tokio::test]
async fn resolve_target_release_rejects_manifest_version_mismatch_for_explicit_version() {
let platform_key = current_platform_key().expect("platform key should resolve");
let manifest = serde_json::json!({
"version": "v4.6.4",
"platforms": {
platform_key: {
"url": "https://example.com/cc-switch.tar.gz",
"signature": "fake-signature"
}
}
});
let app = Router::new().route(
"/team/cc-switch-cli/releases/download/v4.6.3/latest.json",
get(move || {
let manifest = manifest.clone();
async move { axum::Json(manifest) }
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let err = resolve_target_release(&client, &repo_url, Some("v4.6.3"))
.await
.expect_err("mismatched manifest version must fail");
assert!(err.to_string().contains("does not match requested version"));
server.abort();
}
#[tokio::test]
async fn resolve_target_release_falls_back_only_when_manifest_is_missing() {
let app = Router::new()
.route(
"/team/cc-switch-cli/releases/latest/download/latest.json",
get(|| async { axum::http::StatusCode::NOT_FOUND }),
)
.route(
"/api/v3/repos/team/cc-switch-cli/releases/latest",
get(|| async {
axum::Json(serde_json::json!({
"tag_name": "v4.6.3",
"assets": []
}))
}),
)
.route(
"/api/v3/repos/team/cc-switch-cli/releases/tags/v4.6.3",
get(|| async {
axum::Json(serde_json::json!({
"tag_name": "v4.6.3",
"assets": []
}))
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let release = resolve_target_release(&client, &repo_url, None)
.await
.expect("404 manifest should fall back to legacy release");
assert!(matches!(
release,
ResolvedRelease::Legacy { ref target_tag, .. } if target_tag == "v4.6.3"
));
server.abort();
}
#[tokio::test]
async fn resolve_target_release_does_not_fallback_when_manifest_is_invalid() {
let app = Router::new()
.route(
"/team/cc-switch-cli/releases/latest/download/latest.json",
get(|| async { "not-json" }),
)
.route(
"/api/v3/repos/team/cc-switch-cli/releases/latest",
get(|| async {
axum::Json(serde_json::json!({
"tag_name": "v4.6.3",
"assets": []
}))
}),
)
.route(
"/api/v3/repos/team/cc-switch-cli/releases/tags/v4.6.3",
get(|| async {
axum::Json(serde_json::json!({
"tag_name": "v4.6.3",
"assets": []
}))
}),
);
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
axum::serve(listener, app).await.expect("server should run");
});
let client = create_http_client().expect("http client should initialize");
let repo_url = format!("http://{addr}/team/cc-switch-cli");
let err = resolve_target_release(&client, &repo_url, None)
.await
.expect_err("invalid manifest should not fall back to legacy release");
assert!(err.to_string().contains("Failed to parse update manifest"));
server.abort();
}
#[test]
fn verify_minisign_signature_accepts_valid_signature() {
let payload = br#"{"version":"v4.6.3"}"#;
let KeyPair { pk, sk } =
KeyPair::generate_unencrypted_keypair().expect("key pair should generate");
let signature = minisign::sign(None, &sk, Cursor::new(payload), None, None)
.expect("payload should sign")
.to_string();
let public_key = pk.to_box().expect("public key box").to_string();
verify_minisign_signature(payload, &signature, &public_key).expect("signature should verify");
}