mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-22 05:32:44 -04:00
Resolves <https://github.com/pnpm/pacquet/issues/336>. ## Summary Ports pnpm v11's auth flow so `pacquet install` can talk to private registries on the same footing as `pnpm install`. Pacquet now parses credentials out of `.npmrc`, expands `${VAR}` references against the process environment, and attaches the right `Authorization` header on every metadata fetch and tarball download — including tarballs hosted on a CDN host that differs from the registry host. Upstream reference: [`pnpm/pnpm@601317e7a3`](https://github.com/pnpm/pnpm/tree/601317e7a3). ## What's wired up * `_auth`, `_authToken`, `username`, and `_password` keys, both default-registry (bare) and per-registry (`//host[:port]/path/:_authToken=…` family), parsed in `crates/config` (formerly `crates/npmrc`, renamed by #420). * `${VAR}` and `${VAR:-default}` substitution with backslash-escape semantics, ported from [`@pnpm/config.env-replace`](https://github.com/pnpm/components/blob/9c2bd17/config/env-replace/env-replace.ts). Unresolvable placeholders surface a `tracing::warn!` and leave the value verbatim — same best-effort behaviour as pnpm's [`substituteEnv`](https://github.com/pnpm/pnpm/blob/601317e7a3/config/reader/src/loadNpmrcFiles.ts#L156-L162). * URL-keyed lookup (`AuthHeaders`) in `crates/network`, mirroring [`createGetAuthHeaderByURI`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/index.ts): nerf-darts the request URL, walks parent path prefixes from longest to host-only, falls back to a port-stripped lookup matching upstream's [`removePort`](https://github.com/pnpm/pnpm/blob/601317e7a3/network/auth-header/src/helpers/removePort.ts) (any port, not just protocol defaults), and prefers inline `user:password@` basic auth when present. * Default-registry creds key against the *resolved* registry. The two-phase `apply_registry_and_warn` / `build_auth_headers` split inside `Config::current` ensures `pnpm-workspace.yaml`'s `registry` override propagates to the bearer-token nerf-dart key. * The `Authorization` header is attached on `Package::fetch_from_registry`, `PackageVersion::fetch_from_registry`, and inside `DownloadTarballToStore`'s retry loop. ## Test porting checklist Boxes ticked in `plans/TEST_PORTING.md` for the upstream auth tests this PR ports: * `network/auth-header/test/getAuthHeadersFromConfig.test.ts` — `should convert auth token to Bearer header`, `should convert basicAuth to Basic header`, `should handle default registry auth (empty key)`. * `network/auth-header/test/getAuthHeaderByURI.ts` — all 7 entries (`getAuthHeaderByURI()`, basic-auth-without-settings, basic-auth-with-settings, https-port-443, default-ports, registry-with-pathnames, default-registry-auth). * `config/reader/test/parseCreds.test.ts` — `authToken`, `authPairBase64`, `authUsername and authPassword`. The remaining auth checkboxes (`tokenHelper`, `pnpm auth file` precedence, redirect header-stripping, the `installing/deps-installer/test/install/auth.ts` integration tests) need features pacquet doesn't yet have — token helpers, the `auth.ini` layer, scoped registries, redirect handling — and stay open for follow-ups. ## Notes / scope * `always-auth` is intentionally not honoured. pnpm v11 doesn't either — the auth header is selected by URL match alone, and `always-auth` was deprecated upstream. * TLS / proxy / scoped `@scope:registry` keys remain unparsed; the parser silently ignores them, matching the pre-#336 behaviour. The `creds_by_uri` shape is ready to grow as those land. * The 401/403/404 fail-fast retry policy in `crates/tarball` was already in place from #259; this PR's auth header attaches before the retry loop, so the policy still applies unchanged. * Env-var injection follows the trait-per-capability DI pattern from [pnpm/pacquet#339](https://github.com/pnpm/pacquet/issues/339): `pub trait EnvVar` + `pub struct RealApi` in `crates/config/src/api.rs`, threaded through `env_replace`, `NpmrcAuth::from_ini`, and `Config::current` under one `Api: EnvVar` bound. Production callers turbofish `RealApi` explicitly: `Config::current::<RealApi, _, _, _, _>(...)`.
312 lines
13 KiB
Rust
312 lines
13 KiB
Rust
pub mod _utils;
|
|
pub use _utils::*;
|
|
|
|
use assert_cmd::prelude::*;
|
|
use command_extra::CommandExtra;
|
|
use pacquet_testing_utils::{
|
|
bin::{AddMockedRegistry, CommandTempCwd},
|
|
fs::{get_all_files, get_all_folders, is_symlink_or_junction},
|
|
};
|
|
use pipe_trait::Pipe;
|
|
use std::fs;
|
|
|
|
use pacquet_testing_utils::fixtures::{BIG_LOCKFILE, BIG_MANIFEST};
|
|
use std::{fs::OpenOptions, io::Write};
|
|
|
|
#[test]
|
|
fn should_install_dependencies() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json_content = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
|
|
},
|
|
});
|
|
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command...");
|
|
pacquet.with_arg("install").assert().success();
|
|
|
|
eprintln!("Make sure the package is installed");
|
|
let symlink_path = workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin-parent");
|
|
assert!(is_symlink_or_junction(&symlink_path).unwrap());
|
|
let virtual_path =
|
|
workspace.join("node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin-parent@1.0.0");
|
|
assert!(virtual_path.exists());
|
|
|
|
eprintln!("Make sure it installs direct dependencies");
|
|
assert!(!workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin").exists());
|
|
assert!(workspace.join("node_modules/.pnpm/@pnpm.e2e+hello-world-js-bin@1.0.0").exists());
|
|
|
|
eprintln!("Snapshot");
|
|
let workspace_folders = get_all_folders(&workspace);
|
|
let store_files = get_all_files(&store_dir);
|
|
insta::assert_debug_snapshot!((workspace_folders, store_files));
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
#[test]
|
|
fn should_install_exec_files() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json_content = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
|
|
},
|
|
});
|
|
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command...");
|
|
pacquet.with_arg("install").assert().success();
|
|
|
|
eprintln!("Listing all files in the store...");
|
|
let store_files = get_all_files(&store_dir);
|
|
|
|
#[cfg(unix)]
|
|
{
|
|
use pacquet_testing_utils::fs::is_path_executable;
|
|
use pretty_assertions::assert_eq;
|
|
use std::{fs::File, iter::repeat, os::unix::fs::MetadataExt};
|
|
|
|
eprintln!("All files that end with '-exec' are executable, others not");
|
|
let (suffix_exec, suffix_other) =
|
|
store_files.iter().partition::<Vec<_>, _>(|path| path.ends_with("-exec"));
|
|
let (mode_exec, mode_other) = store_files
|
|
.iter()
|
|
.partition::<Vec<_>, _>(|name| store_dir.join(name).as_path().pipe(is_path_executable));
|
|
assert_eq!((&suffix_exec, &suffix_other), (&mode_exec, &mode_other));
|
|
|
|
eprintln!("All executable files have mode 755");
|
|
let actual_modes: Vec<_> = mode_exec
|
|
.iter()
|
|
.map(|name| {
|
|
let mode = store_dir
|
|
.join(name)
|
|
.pipe(File::open)
|
|
.expect("open file to get mode")
|
|
.metadata()
|
|
.expect("get metadata")
|
|
.mode();
|
|
(name.as_str(), mode & 0o777)
|
|
})
|
|
.collect();
|
|
let expected_modes: Vec<_> =
|
|
mode_exec.iter().map(|name| name.as_str()).zip(repeat(0o755)).collect();
|
|
assert_eq!(&actual_modes, &expected_modes);
|
|
}
|
|
|
|
eprintln!("Snapshot");
|
|
insta::assert_debug_snapshot!(store_files);
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
#[test]
|
|
fn should_install_index_files() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json_content = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
|
|
},
|
|
});
|
|
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command...");
|
|
pacquet.with_arg("install").assert().success();
|
|
|
|
eprintln!("Snapshot");
|
|
let index_file_contents = index_file_contents(&store_dir);
|
|
insta::assert_yaml_snapshot!(index_file_contents);
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
// Ignored on CI: the test drives the mocked verdaccio with hundreds of
|
|
// concurrent tarball fetches and reliably reports ConnectionAborted (Windows) /
|
|
// ConnectionReset (macOS) / ConnectionClosed (Ubuntu) on hosted runners. Run
|
|
// manually with `just registry-mock launch` + `cargo test --test install -- --ignored
|
|
// frozen_lockfile_should_be_able_to_handle_big_lockfile`.
|
|
#[ignore = "flaky on CI: mocked verdaccio drops connections under concurrent load"]
|
|
#[test]
|
|
fn frozen_lockfile_should_be_able_to_handle_big_lockfile() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
fs::write(manifest_path, BIG_MANIFEST).expect("write to package.json");
|
|
|
|
eprintln!("Creating pnpm-lock.yaml...");
|
|
let lockfile_path = workspace.join("pnpm-lock.yaml");
|
|
fs::write(lockfile_path, BIG_LOCKFILE).expect("write to pnpm-lock.yaml");
|
|
|
|
eprintln!("Patching .npmrc...");
|
|
let npmrc_path = workspace.join(".npmrc");
|
|
OpenOptions::new()
|
|
.append(true)
|
|
.open(npmrc_path)
|
|
.expect("open .npmrc to append")
|
|
.write_all(b"\nlockfile=true\n")
|
|
.expect("append to .npmrc");
|
|
|
|
eprintln!("Executing command...");
|
|
pacquet.with_args(["install", "--frozen-lockfile"]).assert().success();
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
/// Regression test for the NDJSON `prefix` field. `--reporter=ndjson`
|
|
/// must emit each bunyan envelope with the canonicalized install root
|
|
/// — not the relative `"."` that `dir.join("package.json").parent()`
|
|
/// produced when `--dir` defaulted to `.`. The downstream consumer
|
|
/// (`@pnpm/cli.default-reporter` running in a separate process) compares
|
|
/// every event's `prefix` to its own `process.cwd()` and prepends a
|
|
/// redundant `<prefix> | ` adornment whenever they disagree, so a `"."`
|
|
/// prefix made every progress / stats line render with `. | `.
|
|
#[test]
|
|
fn install_emits_canonical_prefix_in_ndjson_events() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
|
|
},
|
|
});
|
|
fs::write(&manifest_path, package_json.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command with --reporter=ndjson...");
|
|
let output =
|
|
pacquet.with_args(["--reporter=ndjson", "install"]).output().expect("run pacquet install");
|
|
assert!(
|
|
output.status.success(),
|
|
"pacquet install exited non-zero: stderr={}",
|
|
String::from_utf8_lossy(&output.stderr),
|
|
);
|
|
|
|
eprintln!("Collecting `prefix` values from NDJSON stderr...");
|
|
let stderr = String::from_utf8(output.stderr).expect("stderr is utf-8");
|
|
let prefixes: Vec<String> = stderr
|
|
.lines()
|
|
.filter_map(|line| serde_json::from_str::<serde_json::Value>(line).ok())
|
|
.filter_map(|val| val.get("prefix").and_then(|p| p.as_str()).map(str::to_owned))
|
|
.collect();
|
|
assert!(
|
|
!prefixes.is_empty(),
|
|
"expected at least one event with a `prefix` field; stderr was:\n{stderr}",
|
|
);
|
|
|
|
let expected = dunce::canonicalize(&workspace).expect("canonicalize workspace");
|
|
let expected = expected.to_str().expect("workspace path is UTF-8");
|
|
for prefix in &prefixes {
|
|
assert_eq!(
|
|
prefix, expected,
|
|
"every event's prefix must be the canonicalized install root, not relative",
|
|
);
|
|
}
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
#[test]
|
|
fn should_install_circular_dependencies() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { mock_instance, .. } = npmrc_info;
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json_content = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/circular-deps-1-of-2": "1.0.2",
|
|
},
|
|
});
|
|
fs::write(manifest_path, package_json_content.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command...");
|
|
pacquet.with_arg("install").assert().success();
|
|
|
|
assert!(workspace.join("./node_modules/@pnpm.e2e/circular-deps-1-of-2").exists());
|
|
assert!(workspace.join("./node_modules/.pnpm/@pnpm.e2e+circular-deps-1-of-2@1.0.2").exists());
|
|
assert!(workspace.join("./node_modules/.pnpm/@pnpm.e2e+circular-deps-2-of-2@1.0.2").exists());
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|
|
|
|
/// End-to-end coverage for `${VAR}` substitution in `.npmrc`.
|
|
///
|
|
/// `<RealApi as EnvVar>::var` (the `std::env::var` bridge in
|
|
/// `crates/config/src/api.rs`) is unreachable by every other test
|
|
/// because `add_mocked_registry` writes literal values, so
|
|
/// `env_replace` short-circuits at the no-`$` branch.
|
|
///
|
|
/// This test rewrites the registry URL to `${PACQUET_TEST_REGISTRY}`,
|
|
/// sets that variable on the spawned process, and asserts the install
|
|
/// succeeds. The auth-token `${VAR}` substitution path covered by
|
|
/// upstream's [`installing/deps-installer/test/install/auth.ts`](https://github.com/pnpm/pnpm/blob/601317e7a3/installing/deps-installer/test/install/auth.ts)
|
|
/// is not exercised here. The mock registry doesn't gate on auth, so
|
|
/// substituting the registry URL is the smallest scenario that drives
|
|
/// `<RealApi as EnvVar>::var` end-to-end. Token-substitution coverage
|
|
/// belongs in a test against a registry that actually validates the
|
|
/// header.
|
|
#[test]
|
|
fn install_resolves_env_var_in_npmrc_registry() {
|
|
let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } =
|
|
CommandTempCwd::init().add_mocked_registry();
|
|
let AddMockedRegistry { mock_instance, npmrc_path, .. } = npmrc_info;
|
|
|
|
eprintln!("Patching .npmrc to use ${{PACQUET_TEST_REGISTRY}}...");
|
|
// Replace the literal `registry=` line written by
|
|
// `add_mocked_registry` with one that references an env var.
|
|
// Keep the other lines (`store-dir`, `cache-dir`) intact.
|
|
let mocked_registry_url = mock_instance.url();
|
|
let original = fs::read_to_string(&npmrc_path).expect("read .npmrc");
|
|
let patched = original
|
|
.replace(&format!("registry={mocked_registry_url}"), "registry=${PACQUET_TEST_REGISTRY}");
|
|
eprintln!("npmrc_path={npmrc_path:?}\noriginal_npmrc={original:?}\npatched_npmrc={patched:?}");
|
|
assert_ne!(original, patched, ".npmrc layout drifted; update this test");
|
|
fs::write(&npmrc_path, &patched).expect("rewrite .npmrc");
|
|
|
|
eprintln!("Creating package.json...");
|
|
let manifest_path = workspace.join("package.json");
|
|
let package_json_content = serde_json::json!({
|
|
"dependencies": {
|
|
"@pnpm.e2e/hello-world-js-bin-parent": "1.0.0",
|
|
},
|
|
});
|
|
fs::write(&manifest_path, package_json_content.to_string()).expect("write to package.json");
|
|
|
|
eprintln!("Executing command with PACQUET_TEST_REGISTRY set...");
|
|
pacquet
|
|
.with_env("PACQUET_TEST_REGISTRY", &mocked_registry_url)
|
|
.with_arg("install")
|
|
.assert()
|
|
.success();
|
|
|
|
eprintln!("Make sure the package was actually fetched from the resolved registry");
|
|
let symlink_path = workspace.join("node_modules/@pnpm.e2e/hello-world-js-bin-parent");
|
|
let installed = is_symlink_or_junction(&symlink_path).unwrap();
|
|
eprintln!("symlink_path={symlink_path:?} installed={installed}");
|
|
assert!(installed, "expected installed symlink/junction at {symlink_path:?}");
|
|
|
|
drop((root, mock_instance)); // cleanup
|
|
}
|