Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

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"
            );
        }
    }
}