use super::Fixture; use dever::compiler::project::dependency::library::npm::{self, Kind, Omission}; use serde_json::json; use std::collections::BTreeMap; #[test] fn npm_scoped_metadata_uses_strict_official_url_policy() { let mut fixture = Fixture::new(); fixture.package( json!({"name":"app","version":"1.0.0","dependencies":{"@types/node":"^22"}}), BTreeMap::new(), ); fixture.package( json!({"name":"@types/node","version":"22.10.0"}), BTreeMap::new(), ); fixture.resolve(&["npm:app@1.0.0"]).unwrap(); for address in [ "https://registry.npmjs.org/@types/node", "https://registry.npmjs.org/@types/node/22.10.0", "https://registry.npmjs.org/@types/node/-/archive-22.10.0.tgz", ] { assert!(fixture.calls.borrow().iter().any(|url| url == address)); } let graph = fixture.graph(); let node = graph .instances .iter() .find(|node| node.spec.name == "@types/node") .unwrap(); let files = fixture.install().unwrap(); assert_eq!( files[&format!("{}/index.js", node.path)], b"module.exports = 42;" ); let lib = fixture .lock .libs .iter() .find(|lib| lib.spec.name == "@types/node") .unwrap(); let mut source = lib.artifacts[0].source.clone().unwrap(); for locator in [ "https://registry.npmjs.org/@types%2fnode/-/archive-22.10.0.tgz", "https://registry.npmjs.org/@types%2Fnode/-/archive-22.10.0.tgz", "https://registry.npmjs.org/@types/node/%2e%2e/archive-22.10.0.tgz", ] { source.locator = locator.into(); assert!(source.validate(&lib.spec).is_err()); } } #[test] fn npm_nested_instances_keep_peer_context_and_install_offline() { let mut fixture = Fixture::new(); for document in [ json!({"name":"left","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"1.0.0"}}), json!({"name":"right","version":"1.0.0","dependencies":{"plugin":"1.0.0","engine":"2.0.0"}}), json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":">=1 <3"}}), json!({"name":"engine","version":"1.0.0"}), json!({"name":"engine","version":"2.0.0"}), ] { fixture.package(document, BTreeMap::new()); } fixture .resolve(&["npm:left@1.0.0", "npm:right@1.0.0"]) .unwrap(); let graph = fixture.graph(); for root in ["left", "right"] { let plugin = graph .instances .iter() .find(|node| node.path == format!("node_modules/{root}/node_modules/plugin")) .unwrap(); assert_eq!(plugin.edges[0].kind, Kind::Peer); assert_eq!( plugin.edges[0].target.as_deref(), Some(format!("node_modules/{root}/node_modules/engine").as_str()) ); } fixture.documents.clear(); let calls = fixture.calls.borrow().len(); let files = fixture.install().unwrap(); assert_eq!(files.len(), 12); assert_eq!(fixture.calls.borrow().len(), calls); assert_eq!(fixture.lock.libs.len(), 5); } #[test] fn npm_peer_conflict_backtracks_versions_and_incompatible_roots_fail_atomically() { let mut fixture = Fixture::new(); for document in [ json!({"name":"app","version":"1.0.0","dependencies":{"plugin":"*","engine":"1.0.0"}}), json!({"name":"plugin","version":"2.0.0","peerDependencies":{"engine":"2.0.0"}}), json!({"name":"plugin","version":"1.0.0","peerDependencies":{"engine":"1.0.0"}}), json!({"name":"engine","version":"1.0.0"}), json!({"name":"engine","version":"2.0.0"}), ] { fixture.package(document, BTreeMap::new()); } fixture.resolve(&["npm:app@1.0.0"]).unwrap(); assert_eq!( fixture .graph() .instances .iter() .find(|node| node.spec.name == "plugin") .unwrap() .spec .version, "1.0.0" ); let before = fixture.lock.encode().unwrap(); assert!( fixture .resolve(&["npm:engine@1.0.0", "npm:engine@2.0.0"]) .unwrap_err() .contains("incompatible") ); assert_eq!(fixture.lock.encode().unwrap(), before); } #[test] fn npm_optional_target_and_absent_peer_are_explicit_but_integrity_errors_are_fatal() { let mut fixture = Fixture::new(); fixture.package(json!({"name":"app","version":"1.0.0","optionalDependencies":{"wrong":"*","missing":"*"},"peerDependencies":{"peer":"*"},"peerDependenciesMeta":{"peer":{"optional":true}}}),BTreeMap::new()); fixture.package( json!({"name":"wrong","version":"1.0.0","os":["!linux"]}), BTreeMap::new(), ); fixture.resolve(&["npm:app@1.0.0"]).unwrap(); let graph = fixture.graph(); let edges = &graph.instances[0].edges; for (name, reason) in [ ("wrong", Omission::Target), ("missing", Omission::Unavailable), ("peer", Omission::OptionalPeerAbsent), ] { assert_eq!( edges .iter() .find(|edge| edge.name == name) .unwrap() .omission, Some(reason) ); } fixture.install().unwrap(); let before = fixture.lock.encode().unwrap(); assert!( fixture .resolve(&["npm:missing@1.0.0"]) .unwrap_err() .contains("no npm version") ); assert_eq!(fixture.lock.encode().unwrap(), before); fixture.documents.insert( "https://registry.npmjs.org/wrong/-/archive-1.0.0.tgz".into(), b"corrupt optional archive".to_vec(), ); assert!( fixture .resolve(&["npm:app@1.0.0"]) .unwrap_err() .contains("integrity") ); assert_eq!(fixture.lock.encode().unwrap(), before); } #[test] fn npm_one_version_cannot_change_bytes_between_worker_groups() { let mut fixture = Fixture::new(); let metadata = json!({"name":"shared","version":"1.0.0"}); fixture.package(metadata.clone(), BTreeMap::new()); fixture.resolve(&["npm:shared@1.0.0"]).unwrap(); let before = fixture.lock.encode().unwrap(); fixture.package( metadata, BTreeMap::from([("package/changed.js".into(), b"different bytes".to_vec())]), ); let error = fixture.resolve(&["npm:shared@1.0.0"]).unwrap_err(); assert!(error.contains("artifact differs"), "{error}"); assert_eq!(fixture.lock.encode().unwrap(), before); } #[test] fn npm_bundled_packages_have_one_owner_and_tampered_manifest_or_graph_fails() { let mut fixture = Fixture::new(); fixture.package(json!({"name":"app","version":"1.0.0","dependencies":{"tiny":"1.0.0"},"bundleDependencies":["tiny"]}),BTreeMap::from([ ("package/node_modules/tiny/package.json".into(),serde_json::to_vec(&json!({"name":"tiny","version":"1.0.0"})).unwrap()), ("package/node_modules/tiny/index.js".into(),b"module.exports=7".to_vec()), ])); fixture.resolve(&["npm:app@1.0.0"]).unwrap(); assert_eq!(fixture.lock.libs.len(), 1); assert_eq!(fixture.install().unwrap().len(), 4); let original = fixture.lock.npm[0].clone(); fixture.lock.npm[0]["instances"][0]["edges"][0]["requirement"] = json!("2.0.0"); assert!(fixture.install().unwrap_err().contains("range")); fixture.lock.npm[0] = original; let mut graph = fixture.graph(); graph.instances[1].source.prefix = "package/".into(); assert!( npm::validate(&graph, &fixture.lock) .unwrap_err() .contains("owned") ); } #[test] fn npm_registry_and_archive_metadata_must_agree_and_urls_are_official() { let mut fixture = Fixture::new(); fixture.package(json!({"name":"app","version":"1.0.0"}), BTreeMap::new()); let address = "https://registry.npmjs.org/app/1.0.0"; let original = fixture.documents[address].clone(); let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); metadata["dependencies"] = json!({"hidden":"1.0.0"}); fixture .documents .insert(address.into(), serde_json::to_vec(&metadata).unwrap()); assert!( fixture .resolve(&["npm:app@1.0.0"]) .unwrap_err() .contains("metadata") ); let mut metadata: serde_json::Value = serde_json::from_slice(&original).unwrap(); metadata["dist"]["tarball"] = json!("https://attacker.invalid/app.tgz"); fixture .documents .insert(address.into(), serde_json::to_vec(&metadata).unwrap()); assert!( fixture .resolve(&["npm:app@1.0.0"]) .unwrap_err() .contains("official") ); assert!( !fixture .calls .borrow() .iter() .any(|url| url.contains("attacker")) ); } #[test] fn npm_cycles_close_without_infinite_nested_copies_and_missing_build_receipt_fails() { let mut fixture = Fixture::new(); fixture.package( json!({"name":"alpha","version":"1.0.0","dependencies":{"beta":"1.0.0"}}), BTreeMap::new(), ); fixture.package( json!({"name":"beta","version":"1.0.0","dependencies":{"alpha":"1.0.0"}}), BTreeMap::new(), ); fixture.resolve(&["npm:alpha@1.0.0"]).unwrap(); assert_eq!(fixture.graph().instances.len(), 2); assert_eq!(fixture.install().unwrap().len(), 4); fixture.package( json!({"name":"hook","version":"1.0.0","scripts":{"install":"must not run"}}), BTreeMap::new(), ); fixture.resolve(&["npm:hook@1.0.0"]).unwrap(); let graph = fixture .lock .npm .iter() .find(|value| value["roots"][0]["name"] == "hook") .unwrap() .clone(); fixture.lock.npm = vec![graph]; assert!(fixture.install().unwrap_err().contains("lifecycle")); fixture.lock.npm[0]["build"] = json!("0".repeat(64)); assert!(fixture.install().unwrap_err().contains("receipt")); }