server/repository/znippy-npm/tests/plugin_read_view.rs
//! Holger npm backend serve test, through a REAL compressed archive built with the
//! npm plugin (so the authoritative name/version columns from `package.json` are
//! present). Proves the backend resolves a **scoped** package via the typed view —
//! the case the filename loses (a registry tarball for `@acme/widget` is named
//! `widget-2.3.4.tgz`, scope dropped). Also proves R2: the packument tarball URL
//! uses the configured base URL, not a hardcoded `127.0.0.1`.
use std::fs;
use std::path::Path;
use holger_znippy_npm_repository::NpmRepoZnippy;
use traits::{ArtifactId, RepositoryBackendTrait};
use znippy_common::plugin::PluginRegistry;
use znippy_common::plugins::npm_native::NpmPlugin;
use znippy_compress::compress_dir;
/// A real npm `.tgz`: gzip+tar with `package/package.json` carrying the
/// authoritative (possibly scoped) name + version. The scope lives ONLY here.
fn make_npm_tgz(name: &str, version: &str, extra: &str) -> Vec<u8> {
let package_json = format!("{{\"name\":\"{name}\",\"version\":\"{version}\"{extra}}}");
let mut tar_data = Vec::new();
{
let enc = flate2::write::GzEncoder::new(&mut tar_data, flate2::Compression::fast());
let mut tar = tar::Builder::new(enc);
let body = package_json.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_path("package/package.json").unwrap();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, body).unwrap();
tar.finish().unwrap();
}
tar_data
}
fn build_archive(dir: &Path, out: &Path) {
compress_dir(
&dir.to_path_buf(),
&out.to_path_buf(),
false,
Some(&PluginRegistry::with_plugin(Box::new(NpmPlugin))),
None,
None,
)
.expect("compress_dir with npm plugin");
}
#[test]
fn npm_backend_serves_scoped_package_via_view() {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
// Scoped package: filename drops the scope (`widget-2.3.4.tgz`), package.json
// keeps `@acme/widget`. The old filename scan would key it under `widget`.
let scoped = make_npm_tgz("@acme/widget", "2.3.4", "");
let sdir = src.path().join("@acme/widget/-");
fs::create_dir_all(&sdir).unwrap();
fs::write(sdir.join("widget-2.3.4.tgz"), &scoped).unwrap();
let archive = out.path().join("npm.znippy");
build_archive(src.path(), &archive);
let repo = NpmRepoZnippy::with_archive_and_url(
"npm".into(),
archive,
"https://npm.example.com".into(),
)
.unwrap();
// fetch() resolves the scoped GAV through the view → exact bytes. The scope
// arrives as the namespace, the short name as `name`.
let id = ArtifactId {
namespace: Some("@acme".into()),
name: "widget".into(),
version: "2.3.4".into(),
};
let got = repo.fetch(&id).unwrap();
assert_eq!(
got.as_deref(),
Some(scoped.as_slice()),
"scoped package must serve via the view"
);
// Tarball wire path (scope as a literal `/`) resolves via the view.
let url = "/npm/@acme/widget/-/widget-2.3.4.tgz";
let (status, _h, body) = repo.handle_http2_request("GET", url, &[]).unwrap();
assert_eq!(status, 200, "scoped tarball wire path must serve 200");
assert_eq!(body, scoped, "tarball serve must return the exact bytes");
// Packument: authoritative scoped name + R2 — tarball URL uses the configured
// base, NOT a hardcoded 127.0.0.1.
let (pstatus, _ph, pbody) = repo
.handle_http2_request("GET", "/npm/@acme%2fwidget", &[])
.unwrap();
assert_eq!(pstatus, 200, "scoped packument must resolve");
let json = String::from_utf8(pbody).unwrap();
assert!(
json.contains("\"@acme/widget\""),
"packument carries the scoped name: {json}"
);
assert!(
json.contains("https://npm.example.com/npm/@acme/widget/-/widget-2.3.4.tgz"),
"packument tarball URL must use the configured base_url (R2): {json}"
);
assert!(
!json.contains("127.0.0.1"),
"R2: no hardcoded 127.0.0.1 in the packument: {json}"
);
// list() reports the authoritative scoped coords.
let listed = repo.list(None, 0).unwrap();
assert!(
listed
.iter()
.any(|e| e.id.namespace.as_deref() == Some("@acme")
&& e.id.name == "widget"
&& e.id.version == "2.3.4"),
"list must carry the authoritative scoped coords, got: {:?}",
listed
.iter()
.map(|e| (&e.id.namespace, &e.id.name, &e.id.version))
.collect::<Vec<_>>()
);
// Absent coord → None (not a wrong file).
let absent = ArtifactId {
namespace: Some("@acme".into()),
name: "widget".into(),
version: "9.9.9".into(),
};
assert_eq!(repo.fetch(&absent).unwrap(), None);
}
// ───────────────────────── the change token ─────────────────────────
/// `coordinate -> change_token` for one listing, refusing a row without one.
fn token_map(entries: &[traits::ArtifactEntry]) -> std::collections::BTreeMap<String, String> {
entries
.iter()
.map(|e| {
let key = format!(
"{}/{}@{}",
e.id.namespace.clone().unwrap_or_default(),
e.id.name,
e.id.version
);
let tok = e
.change_token
.clone()
.unwrap_or_else(|| panic!("{key}: no change token"));
(key, tok)
})
.collect()
}
/// **Every npm row carries a change token, and it moves only when the bytes do.**
///
/// Two independent seals of the same three tarballs, the second with ONE
/// rewritten (same name, same version, same archive path, a different
/// `package.json` body). The token is folded from the per-chunk checksums the
/// archive index carries for that row, so the two unchanged tarballs fold to
/// the same token across the seals and the rewritten one does not. Before
/// `NpmPackage::relative_path` this backend listed through the typed view and
/// could not name its row, so it stamped nothing.
#[test]
fn npm_entries_carry_a_change_token_that_moves_only_when_the_bytes_do() {
let seal = |rewrite: bool| -> (tempfile::TempDir, std::path::PathBuf) {
let src = tempfile::tempdir().unwrap();
let out = tempfile::tempdir().unwrap();
let lay = |dir: &str, file: &str, bytes: &[u8]| {
let d = src.path().join(dir);
fs::create_dir_all(&d).unwrap();
fs::write(d.join(file), bytes).unwrap();
};
lay(
"@acme/widget/-",
"widget-2.3.4.tgz",
&make_npm_tgz("@acme/widget", "2.3.4", ""),
);
lay(
"left-pad/-",
"left-pad-1.3.0.tgz",
&make_npm_tgz("left-pad", "1.3.0", ""),
);
let extra = if rewrite {
",\"description\":\"rewritten\""
} else {
""
};
lay(
"chalk/-",
"chalk-5.3.0.tgz",
&make_npm_tgz("chalk", "5.3.0", extra),
);
let archive = out.path().join("npm.znippy");
build_archive(src.path(), &archive);
(out, archive)
};
let (_a, first) = seal(false);
let (_b, second) = seal(true);
let before = token_map(
&NpmRepoZnippy::with_archive("npm".into(), first)
.unwrap()
.list(None, 0)
.unwrap(),
);
let after = token_map(
&NpmRepoZnippy::with_archive("npm".into(), second)
.unwrap()
.list(None, 0)
.unwrap(),
);
assert_eq!(
before.len(),
3,
"three rows, every one with a token: {before:?}"
);
assert_eq!(
before.keys().collect::<Vec<_>>(),
after.keys().collect::<Vec<_>>(),
"the same coordinates on both sides"
);
let moved = "/chalk@5.3.0";
assert!(
before.contains_key(moved),
"the rewritten row is listed: {before:?}"
);
for (k, t) in &before {
if k == moved {
assert_ne!(
t, &after[k],
"{k}: the bytes changed, so the token must move"
);
} else {
assert_eq!(
t, &after[k],
"{k}: the bytes did not change, so the token must not move"
);
}
}
}