Skip to main content

crates/aube-registry/src/config/tests.rs

use super::*;
use base64::Engine as _;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

#[test]
fn parse_npmrc_strips_utf8_bom() {
    let dir = tempfile::tempdir().unwrap();
    let rc = dir.path().join(".npmrc");
    std::fs::write(&rc, "\u{feff}registry=https://r.example.com\n").unwrap();
    let entries = parse_npmrc(&rc).unwrap();
    assert_eq!(
        entries,
        vec![("registry".to_string(), "https://r.example.com".to_string())]
    );
}

#[test]
fn scoped_registry_lookup_is_case_insensitive() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "@MyOrg:registry=https://myorg.example.com/\n",
    )
    .unwrap();
    let cfg = NpmConfig::load_isolated(dir.path());
    assert_eq!(cfg.registry_for("@myorg/pkg"), "https://myorg.example.com/");
}

#[test]
fn test_parse_npmrc_basic() {
    let dir = tempfile::tempdir().unwrap();
    let rc = dir.path().join(".npmrc");
    std::fs::write(
        &rc,
        "registry=https://registry.example.com\n_authToken=secret123\n",
    )
    .unwrap();

    let entries = parse_npmrc(&rc).unwrap();
    assert_eq!(entries.len(), 2);
    assert_eq!(
        entries[0],
        (
            "registry".to_string(),
            "https://registry.example.com".to_string()
        )
    );
    assert_eq!(
        entries[1],
        ("_authToken".to_string(), "secret123".to_string())
    );
}

#[test]
fn test_parse_npmrc_comments_and_blanks() {
    let dir = tempfile::tempdir().unwrap();
    let rc = dir.path().join(".npmrc");
    std::fs::write(
        &rc,
        "# comment\n\n; another comment\nregistry=https://r.com\n",
    )
    .unwrap();

    let entries = parse_npmrc(&rc).unwrap();
    assert_eq!(entries.len(), 1);
}

#[test]
fn test_substitute_env() {
    // Use a unique var name and unsafe block (required in edition 2024)
    unsafe { std::env::set_var("AUBE_TEST_TOKEN_CFG", "mytoken") };
    assert_eq!(substitute_env("${AUBE_TEST_TOKEN_CFG}"), "mytoken");
    assert_eq!(
        substitute_env("prefix-${AUBE_TEST_TOKEN_CFG}-suffix"),
        "prefix-mytoken-suffix"
    );
    assert_eq!(substitute_env("no-vars-here"), "no-vars-here");
    unsafe { std::env::remove_var("AUBE_TEST_TOKEN_CFG") };
}

#[test]
fn test_substitute_env_missing_var() {
    assert_eq!(substitute_env("${AUBE_DEFINITELY_NOT_SET}"), "");
}

#[test]
fn parse_npmrc_strips_surrounding_quotes() {
    let dir = tempfile::tempdir().unwrap();
    let rc = dir.path().join(".npmrc");
    std::fs::write(
        &rc,
        "//artifactory.example.com/api/npm/virtual-npm/:_auth=\"token==\"\n\
             //registry.example.com/:_authToken='single-quoted'\n\
             registry=\"https://r.example.com/\"\n\
             unmatched=\"only-leading\n\
             plain=value\n",
    )
    .unwrap();

    let entries = parse_npmrc(&rc).unwrap();
    assert_eq!(
        entries,
        vec![
            (
                "//artifactory.example.com/api/npm/virtual-npm/:_auth".to_string(),
                "token==".to_string()
            ),
            (
                "//registry.example.com/:_authToken".to_string(),
                "single-quoted".to_string()
            ),
            ("registry".to_string(), "https://r.example.com/".to_string()),
            ("unmatched".to_string(), "\"only-leading".to_string()),
            ("plain".to_string(), "value".to_string()),
        ]
    );
}

#[test]
fn parse_npmrc_expands_env_in_keys_for_per_uri_auth() {
    // Regression for endevco/aube#519. Nexus / Artifactory setups
    // commonly template the registry-prefix portion of per-URI
    // auth keys via env vars injected by sops/CI:
    //
    //     ${NEXUS_NPM_AUTH_URL}:_auth=${NEXUS_NPM_TOKEN}
    //
    // pnpm/npm both expand `${VAR}` on the key side as well as
    // the value side, so the entry lands in `auth_by_uri` keyed
    // by the real host. Without key-side expansion the entry was
    // stored under the literal `${NEXUS_NPM_AUTH_URL}` and the
    // tarball request never picked up the basic-auth credential.
    //
    // RAII guard so a panic between `set_var` and the manual
    // cleanup can't leak these names into the rest of the test
    // run (the harness runs cases in parallel threads on shared
    // process-wide env).
    struct EnvVars(&'static [&'static str]);
    impl Drop for EnvVars {
        fn drop(&mut self) {
            for name in self.0 {
                unsafe { std::env::remove_var(name) };
            }
        }
    }
    let _vars = EnvVars(&["AUBE_TEST_NEXUS_HOST_CFG", "AUBE_TEST_NEXUS_TOKEN_CFG"]);
    unsafe {
        std::env::set_var(
            "AUBE_TEST_NEXUS_HOST_CFG",
            "//nexus.example.com/repository/npm/",
        );
        std::env::set_var("AUBE_TEST_NEXUS_TOKEN_CFG", "dXNlcjpwYXNz");
    }

    let dir = tempfile::tempdir().unwrap();
    let rc = dir.path().join(".npmrc");
    std::fs::write(
        &rc,
        "${AUBE_TEST_NEXUS_HOST_CFG}:_auth=${AUBE_TEST_NEXUS_TOKEN_CFG}\n",
    )
    .unwrap();

    let entries = parse_npmrc(&rc).unwrap();

    assert_eq!(
        entries,
        vec![(
            "//nexus.example.com/repository/npm/:_auth".to_string(),
            "dXNlcjpwYXNz".to_string(),
        )]
    );

    let mut config = NpmConfig::default();
    config.apply(entries);
    assert_eq!(
        config
            .basic_auth_for("https://nexus.example.com/repository/npm/@scope/pkg/-/pkg-1.0.0.tgz"),
        Some("dXNlcjpwYXNz".to_string()),
        "tarball URL under the env-templated host must pick up _auth",
    );
}

#[test]
fn test_package_scope() {
    assert_eq!(package_scope("@myorg/pkg"), Some("@myorg"));
    assert_eq!(package_scope("lodash"), None);
    assert_eq!(package_scope("@types/node"), Some("@types"));
}

#[test]
fn test_registry_uri_key() {
    assert_eq!(
        registry_uri_key("https://registry.example.com/"),
        "//registry.example.com/"
    );
    assert_eq!(
        registry_uri_key("http://localhost:4873/"),
        "//localhost:4873/"
    );
}

#[test]
fn test_registry_uri_key_strips_default_port() {
    // https default port collapses
    assert_eq!(
        registry_uri_key("https://registry.example.com:443/"),
        "//registry.example.com/"
    );
    // http default port collapses
    assert_eq!(
        registry_uri_key("http://registry.example.com:80/artifactory/npm/"),
        "//registry.example.com/artifactory/npm/"
    );
    // Non-default port is preserved
    assert_eq!(
        registry_uri_key("https://registry.example.com:8443/"),
        "//registry.example.com:8443/"
    );
}

#[test]
fn test_registry_uri_key_only_strips_matching_default_port() {
    // https on the http default port (rare but valid) is a *different
    // server* from https on its own default — don't collapse them.
    assert_eq!(registry_uri_key("https://host:80/x/"), "//host:80/x/",);
    // Symmetric case: http on https default port stays distinct.
    assert_eq!(registry_uri_key("http://host:443/x/"), "//host:443/x/",);
}

#[test]
fn test_lookup_by_uri_prefix_longest_match() {
    // Path-scoped auth entry. A tarball URL that lives under the
    // same path should resolve, while an unrelated path should not.
    let mut map: BTreeMap<String, &'static str> = BTreeMap::new();
    map.insert("//host/artifactory/npm/".to_string(), "scoped-token");
    map.insert("//host/".to_string(), "root-token");

    // Full tarball path finds the path-scoped key.
    assert_eq!(
        lookup_by_uri_prefix(&map, "//host/artifactory/npm/lodash/-/lodash-4.17.21.tgz"),
        Some(&"scoped-token"),
    );
    // A request outside the scope falls through to the host root.
    assert_eq!(
        lookup_by_uri_prefix(&map, "//host/other/pkg.tgz"),
        Some(&"root-token"),
    );
    // Different host does not leak root-token.
    assert_eq!(lookup_by_uri_prefix(&map, "//other/foo"), None);
}

#[test]
fn auth_token_resolves_for_path_scoped_registry_with_default_port() {
    // End-to-end: `.npmrc` configures path-scoped auth under a
    // reverse-proxy path, tarball URLs carry an explicit `:443`.
    // Before the fix this 401'd because the lookup key
    // `//host:443/artifactory/npm/lodash/-/lodash-4.17.21.tgz`
    // never matched the stored `//host/artifactory/npm/` key.
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "registry=https://registry.example.com/artifactory/npm/\n\
             //registry.example.com/artifactory/npm/:_authToken=scoped-secret\n",
    )
    .unwrap();

    let config = NpmConfig::load_isolated(dir.path());

    assert_eq!(
        config.auth_token_for(
            "https://registry.example.com:443/artifactory/npm/lodash/-/lodash-4.17.21.tgz"
        ),
        Some("scoped-secret"),
    );
    assert_eq!(
        config.auth_token_for(
            "https://registry.example.com/artifactory/npm/lodash/-/lodash-4.17.21.tgz"
        ),
        Some("scoped-secret"),
    );
}

#[test]
fn npmrc_key_with_default_port_is_normalized_on_ingest() {
    // User wrote `:443` explicitly in `.npmrc`. Lookups that don't
    // carry the port must still resolve.
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "//registry.example.com:443/:_authToken=via-443\n",
    )
    .unwrap();

    let config = NpmConfig::load_isolated(dir.path());
    assert_eq!(
        config.auth_token_for("https://registry.example.com/"),
        Some("via-443"),
    );
}

#[test]
fn test_normalize_registry_url() {
    assert_eq!(normalize_registry_url("https://r.com"), "https://r.com/");
    assert_eq!(normalize_registry_url("https://r.com/"), "https://r.com/");
}

#[test]
fn test_config_load_project_npmrc() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "registry=https://custom.registry.com\n\
             @myorg:registry=https://myorg.registry.com\n\
             //myorg.registry.com/:_authToken=org-secret\n\
             //custom.registry.com/:_authToken=custom-secret\n",
    )
    .unwrap();

    // HOME + env isolation via `load_isolated`: `NpmConfig::load`
    // would layer the developer's real `~/.npmrc` and
    // `NPM_CONFIG_REGISTRY` env var on top of the project file,
    // either of which can shadow the `registry=` we're asserting on.
    let config = NpmConfig::load_isolated(dir.path());

    assert_eq!(config.registry, "https://custom.registry.com/");
    assert_eq!(
        config.registry_for("@myorg/pkg"),
        "https://myorg.registry.com/"
    );
    assert_eq!(
        config.registry_for("lodash"),
        "https://custom.registry.com/"
    );
    assert_eq!(
        config.auth_token_for("https://myorg.registry.com/"),
        Some("org-secret")
    );
    assert_eq!(
        config.auth_token_for("https://custom.registry.com/"),
        Some("custom-secret")
    );
}

#[test]
fn split_username_password_auth_resolves_to_basic_header_payload() {
    let dir = tempfile::tempdir().unwrap();
    let encoded_password = base64::engine::general_purpose::STANDARD.encode("s3cr3t");
    std::fs::write(
        dir.path().join(".npmrc"),
        format!(
            "//registry.example.com/:username=alice\n\
                 //registry.example.com/:_password={encoded_password}\n"
        ),
    )
    .unwrap();

    let config = NpmConfig::load_isolated(dir.path());
    let expected = base64::engine::general_purpose::STANDARD.encode("alice:s3cr3t");
    assert_eq!(
        config.basic_auth_for("https://registry.example.com/"),
        Some(expected),
    );
}

#[test]
fn token_helper_from_project_npmrc_is_refused_kebab_case() {
    // Same regression as `token_helper_from_project_npmrc_is_refused`
    // but using the `token-helper` kebab-case alias that
    // `apply_tagged` also accepts. Confirms the gate fires for
    // both spellings, not just the camelCase key.
    let project = tempfile::tempdir().unwrap();
    std::fs::write(
        project.path().join(".npmrc"),
        "//registry.example.com/:token-helper=/tmp/evil.sh\n",
    )
    .unwrap();

    let home = tempfile::tempdir().unwrap();
    let mut config = NpmConfig::default();
    config.apply_tagged(load_npmrc_entries_tagged_with_home(
        Some(home.path()),
        None,
        project.path(),
        None,
    ));
    assert_eq!(
        config.token_helper_for("https://registry.example.com/"),
        None,
        "project-scope token-helper (kebab-case) must be refused"
    );
}

#[test]
fn token_helper_from_project_npmrc_is_refused() {
    // Regression for the CVE-2025-69262 class: a project-scope
    // `.npmrc` that a hostile repo can commit used to be able
    // to set `tokenHelper`, which aube then spawned via
    // `sh -c <value>` at the next authed registry request.
    let project = tempfile::tempdir().unwrap();
    std::fs::write(
        project.path().join(".npmrc"),
        "//registry.example.com/:tokenHelper=/tmp/evil.sh\n",
    )
    .unwrap();

    let home = tempfile::tempdir().unwrap();
    let mut config = NpmConfig::default();
    config.apply_tagged(load_npmrc_entries_tagged_with_home(
        Some(home.path()),
        None,
        project.path(),
        None,
    ));
    assert_eq!(
        config.token_helper_for("https://registry.example.com/"),
        None,
        "project-scope tokenHelper must be refused"
    );
}

#[test]
fn token_helper_from_user_npmrc_is_accepted() {
    // The user's own `~/.npmrc` is the only file trusted to
    // configure subprocess execution. A valid bare absolute
    // path passes the sanitizer and reaches `token_helper_for`.
    let home = tempfile::tempdir().unwrap();
    let helper_path = if cfg!(windows) {
        "C:\\opt\\aube\\helper.exe"
    } else {
        "/opt/aube/helper"
    };
    std::fs::write(
        home.path().join(".npmrc"),
        format!("//registry.example.com/:tokenHelper={helper_path}\n"),
    )
    .unwrap();

    let project = tempfile::tempdir().unwrap();
    let mut config = NpmConfig::default();
    config.apply_tagged(load_npmrc_entries_tagged_with_home(
        Some(home.path()),
        None,
        project.path(),
        None,
    ));
    assert_eq!(
        config.token_helper_for("https://registry.example.com/"),
        Some(helper_path)
    );
}

#[test]
fn token_helper_from_npmrc_auth_file_is_refused() {
    // `npmrc-auth-file` lets a user point aube at a sidecar
    // `.npmrc` for auth. The path itself can be set from a
    // project `.npmrc`, so the file's contents inherit the
    // project trust level and must not be allowed to set
    // `tokenHelper` either.
    let home = tempfile::tempdir().unwrap();
    let project = tempfile::tempdir().unwrap();
    let auth = project.path().join("auth.rc");
    std::fs::write(&auth, "//registry.example.com/:tokenHelper=/tmp/evil.sh\n").unwrap();
    std::fs::write(
        project.path().join(".npmrc"),
        format!(
            "npmrc-auth-file={}\n",
            auth.to_string_lossy().replace('\\', "/")
        ),
    )
    .unwrap();

    let mut config = NpmConfig::default();
    config.apply_tagged(load_npmrc_entries_tagged_with_home(
        Some(home.path()),
        None,
        project.path(),
        None,
    ));
    assert_eq!(
        config.token_helper_for("https://registry.example.com/"),
        None,
        "tokenHelper from an auth file reachable via project `.npmrc` must be refused"
    );
}

#[test]
fn sanitize_token_helper_accepts_absolute_path() {
    assert_eq!(
        sanitize_token_helper("/usr/local/bin/aws-npm-helper"),
        Some("/usr/local/bin/aws-npm-helper".to_string())
    );
    assert_eq!(
        sanitize_token_helper("C:\\Program.Files\\auth.exe"),
        Some("C:\\Program.Files\\auth.exe".to_string())
    );
    assert_eq!(
        sanitize_token_helper("C:/tools/auth.exe"),
        Some("C:/tools/auth.exe".to_string())
    );
    // UNC paths are absolute on Windows.
    assert_eq!(
        sanitize_token_helper("\\\\server\\share\\auth.exe"),
        Some("\\\\server\\share\\auth.exe".to_string())
    );
}

#[test]
fn sanitize_token_helper_rejects_relative_path() {
    assert!(sanitize_token_helper("aws-helper").is_none());
    assert!(sanitize_token_helper("./aws-helper").is_none());
    assert!(sanitize_token_helper("bin/aws-helper").is_none());
}

#[test]
fn sanitize_token_helper_rejects_shell_metacharacters() {
    // `sh -c` / `cmd /C` would otherwise reinterpret any of
    // these as a pipeline separator or substitution marker.
    for v in [
        "/bin/helper;rm",
        "/bin/helper|rm",
        "/bin/helper&rm",
        "/bin/helper`rm`",
        "/bin/helper$(rm)",
        "/bin/helper>log",
        "/bin/helper<log",
        "/bin/helper*glob",
        "/bin/helper?glob",
        "/bin/helper\"evil",
        "/bin/helper'evil",
    ] {
        assert!(sanitize_token_helper(v).is_none(), "should reject {v:?}");
    }
}

#[test]
fn sanitize_token_helper_rejects_whitespace() {
    // Arguments must not be smuggled into the value. pnpm's
    // tokenHelper contract is a path to an executable, so any
    // extra tokens have to go in a wrapper script.
    assert!(sanitize_token_helper("/bin/helper --flag").is_none());
    assert!(sanitize_token_helper("/bin/helper\targ").is_none());
    assert!(sanitize_token_helper("/bin/helper\nevil").is_none());
}

#[test]
fn sanitize_token_helper_rejects_empty_and_nul() {
    assert!(sanitize_token_helper("").is_none());
    assert!(sanitize_token_helper("   ").is_none());
    assert!(sanitize_token_helper("/bin/helper\0evil").is_none());
}

#[test]
fn sanitize_token_helper_rejects_env_substitution_markers() {
    // `${VAR}` and `$VAR` both fail because `$` is in the
    // metacharacter rejection set. This matches pnpm 10.27.0
    // throwing on env-var tokens in the value.
    assert!(sanitize_token_helper("/bin/helper-${EVIL}").is_none());
    assert!(sanitize_token_helper("/bin/$EVIL").is_none());
}

#[test]
fn per_registry_tls_config_is_parsed() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
            dir.path().join(".npmrc"),
            "//registry.example.com/:ca=-----BEGIN CERTIFICATE-----\\nca\\n-----END CERTIFICATE-----\n\
             //registry.example.com/:cafile=corp-ca.pem\n\
             //registry.example.com/:cert=-----BEGIN CERTIFICATE-----\\nclient\\n-----END CERTIFICATE-----\n\
             //registry.example.com/:key=-----BEGIN PRIVATE KEY-----\\nkey\\n-----END PRIVATE KEY-----\n",
        )
        .unwrap();

    let config = NpmConfig::load_isolated(dir.path());
    let tls = &config
        .registry_config_for("https://registry.example.com/")
        .expect("registry config")
        .tls;
    assert_eq!(tls.ca.len(), 1);
    assert!(tls.ca[0].contains("\nca\n"));
    assert!(!tls.ca[0].contains("\\n"));
    assert_eq!(tls.cafile.as_deref(), Some(Path::new("corp-ca.pem")));
    assert!(tls.cert.as_deref().unwrap().contains("\nclient\n"));
    assert!(tls.key.as_deref().unwrap().contains("\nkey\n"));
}

#[test]
fn top_level_cafile_and_ca_are_parsed() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "cafile=/etc/ssl/corp-bundle.pem\n\
             ca=-----BEGIN CERTIFICATE-----\\nfirst\\n-----END CERTIFICATE-----\n\
             ca[]=-----BEGIN CERTIFICATE-----\\nsecond\\n-----END CERTIFICATE-----\n",
    )
    .unwrap();

    let config = NpmConfig::load_isolated(dir.path());
    assert_eq!(
        config.cafile.as_deref(),
        Some(Path::new("/etc/ssl/corp-bundle.pem"))
    );
    assert_eq!(config.ca.len(), 2);
    assert!(config.ca[0].contains("\nfirst\n"));
    assert!(config.ca[1].contains("\nsecond\n"));
    // Top-level keys must not leak into per-registry config.
    assert!(
        config
            .registry_config_for("https://registry.npmjs.org/")
            .is_none()
    );
}

#[test]
fn test_config_global_auth_token() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join(".npmrc"), "_authToken=global-token\n").unwrap();

    // Isolate from the host's real `~/.npmrc` via `load_isolated`:
    // a developer or CI runner with
    // `//registry.npmjs.org/:_authToken=...` already logged in
    // would have that URI-specific token beat our project-level
    // `_authToken` fallback, since `auth_token_for` checks
    // per-URI auth before dropping to `global_auth_token`.
    let config = NpmConfig::load_isolated(dir.path());
    // Global token used as fallback
    assert_eq!(
        config.auth_token_for("https://registry.npmjs.org/"),
        Some("global-token")
    );
}

#[test]
fn test_config_defaults() {
    let dir = tempfile::tempdir().unwrap();
    // No .npmrc at all. Same HOME isolation rationale as
    // `test_config_global_auth_token` — without it this assertion
    // flakes on any developer box whose `~/.npmrc` has ever been
    // touched by `npm login`.
    let config = NpmConfig::load_isolated(dir.path());
    assert_eq!(config.registry, "https://registry.npmjs.org/");
    assert!(
        config
            .auth_token_for("https://registry.npmjs.org/")
            .is_none()
    );
}

#[test]
fn test_config_scoped_registry_without_auth() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "@private:registry=https://private.registry.com\n",
    )
    .unwrap();

    let config = NpmConfig::load_isolated(dir.path());
    assert_eq!(
        config.registry_for("@private/my-lib"),
        "https://private.registry.com/"
    );
    assert!(
        config
            .auth_token_for("https://private.registry.com/")
            .is_none()
    );
}

#[test]
fn test_http_proxy_inherits_https_proxy() {
    // pnpm's fallback: `httpProxy` inherits whatever `httpsProxy`
    // resolved to when no HTTP-specific value is configured,
    // so a single `https-proxy=` line configures both schemes.
    //
    // We scrub the proxy env vars inside the `apply_proxy_env`
    // helper's view by staging the field value directly: the
    // real resolver is pure once `https_proxy` is already set,
    // so `env_any` is never consulted for the HTTPS half and
    // this assertion can't race a developer's shell.
    let mut config = NpmConfig {
        https_proxy: Some("http://corp.proxy:8080".to_string()),
        ..Default::default()
    };
    // Drop any ambient `HTTP_PROXY` so the second `or_else` in
    // `apply_proxy_env` can't beat us to the fallback. We can't
    // use `std::env::remove_var` safely across parallel tests;
    // instead, pre-populate `http_proxy` to `None` and rely on
    // the field-level fallback only.
    // Since `https_proxy` is already `Some`, the resolver takes
    // that branch first — `env_any("HTTP_PROXY", ...)` is never
    // called.
    config.apply_proxy_env();
    assert_eq!(
        config.http_proxy.as_deref(),
        Some("http://corp.proxy:8080"),
        "http_proxy must inherit https_proxy"
    );
}

#[test]
fn test_npmrc_proxy_key_feeds_https_proxy() {
    // pnpm treats `.npmrc proxy=` as the fallback for
    // `httpsProxy`, not as a direct alias for `httpProxy`.
    let mut config = NpmConfig {
        npmrc_proxy: Some("http://legacy:3128".to_string()),
        ..Default::default()
    };
    config.apply_proxy_env();
    assert_eq!(
        config.https_proxy.as_deref(),
        Some("http://legacy:3128"),
        "legacy `proxy=` key must resolve into https_proxy"
    );
    assert_eq!(
        config.http_proxy.as_deref(),
        Some("http://legacy:3128"),
        "http_proxy then inherits the resolved https_proxy"
    );
}

#[test]
fn test_explicit_https_proxy_wins_over_npmrc_proxy() {
    let mut config = NpmConfig {
        https_proxy: Some("http://explicit:1".to_string()),
        npmrc_proxy: Some("http://fallback:2".to_string()),
        ..Default::default()
    };
    config.apply_proxy_env();
    assert_eq!(config.https_proxy.as_deref(), Some("http://explicit:1"));
}

#[test]
fn test_default_strict_ssl_is_true() {
    // Regression: `NpmConfig::default()` must not leave
    // `strict_ssl = false` (bool::default), because
    // `RegistryClient::new` spreads the default and would
    // otherwise silently disable TLS cert validation.
    let c = NpmConfig::default();
    assert!(c.strict_ssl);
}

#[test]
fn test_parses_proxy_and_ssl_settings() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "https-proxy=http://proxy.example.com:8080\n\
             proxy=http://plain.example.com:3128\n\
             noproxy=localhost,.internal\n\
             strict-ssl=false\n\
             local-address=127.0.0.1\n\
             maxsockets=12\n",
    )
    .unwrap();

    // Isolate from the developer's real ~/.npmrc
    let home = tempfile::tempdir().unwrap();
    let mut config = NpmConfig {
        registry: "https://registry.npmjs.org/".to_string(),
        strict_ssl: true,
        ..Default::default()
    };
    config.apply(load_npmrc_entries_with_home(
        Some(home.path()),
        None,
        dir.path(),
        None,
    ));

    assert_eq!(
        config.https_proxy.as_deref(),
        Some("http://proxy.example.com:8080")
    );
    // `.npmrc proxy=` stores into `npmrc_proxy`, which feeds
    // `https_proxy`/`http_proxy` only via `apply_proxy_env`. We
    // called raw `apply` here, so the field is still the
    // verbatim legacy key.
    assert_eq!(
        config.npmrc_proxy.as_deref(),
        Some("http://plain.example.com:3128")
    );
    assert!(config.http_proxy.is_none());
    assert_eq!(config.no_proxy.as_deref(), Some("localhost,.internal"));
    assert!(!config.strict_ssl);
    assert_eq!(
        config.local_address,
        Some("127.0.0.1".parse::<std::net::IpAddr>().unwrap())
    );
    assert_eq!(config.max_sockets, Some(12));
}

#[test]
fn test_strict_ssl_default_true() {
    let dir = tempfile::tempdir().unwrap();
    let home = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join(".npmrc"), "").unwrap();
    let mut config = NpmConfig {
        strict_ssl: true,
        ..Default::default()
    };
    config.apply(load_npmrc_entries_with_home(
        Some(home.path()),
        None,
        dir.path(),
        None,
    ));
    assert!(config.strict_ssl);
}

#[test]
fn test_camel_case_proxy_aliases() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "httpsProxy=http://a\nhttpProxy=http://b\nnoProxy=foo\nstrictSsl=false\nlocalAddress=::1\n",
    )
    .unwrap();
    let home = tempfile::tempdir().unwrap();
    let mut config = NpmConfig {
        strict_ssl: true,
        ..Default::default()
    };
    config.apply(load_npmrc_entries_with_home(
        Some(home.path()),
        None,
        dir.path(),
        None,
    ));
    assert_eq!(config.https_proxy.as_deref(), Some("http://a"));
    assert_eq!(config.http_proxy.as_deref(), Some("http://b"));
    assert_eq!(config.no_proxy.as_deref(), Some("foo"));
    assert!(!config.strict_ssl);
    assert_eq!(
        config.local_address,
        Some("::1".parse::<std::net::IpAddr>().unwrap())
    );
}

#[test]
fn test_invalid_proxy_values_dropped() {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "local-address=not-an-ip\nmaxsockets=zero\nstrict-ssl=perhaps\n",
    )
    .unwrap();
    let home = tempfile::tempdir().unwrap();
    let mut config = NpmConfig {
        strict_ssl: true,
        ..Default::default()
    };
    config.apply(load_npmrc_entries_with_home(
        Some(home.path()),
        None,
        dir.path(),
        None,
    ));
    assert!(config.local_address.is_none());
    assert!(config.max_sockets.is_none());
    // Garbage boolean leaves the previous value in place.
    assert!(config.strict_ssl);
}

// `auto-install-peers` parsing lives in aube's settings_values
// module now — see tests there. NpmConfig only knows about
// registry-client config (URL, auth, scopes).

#[test]
fn test_load_npmrc_entries_orders_user_before_project() {
    // The downstream settings resolver iterates the returned Vec in
    // reverse to give project-level entries priority, so the
    // invariant this test pins is specifically the ordering: user
    // entries MUST appear before project entries for the same key.
    //
    // Uses `load_npmrc_entries_with_home` (test-only helper) to
    // inject a fake user home rather than mutating `$HOME` on the
    // process, which would race with any other test reading env.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();

    std::fs::write(
        home_dir.path().join(".npmrc"),
        "auto-install-peers=true\nfoo=user-only\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        "auto-install-peers=false\nbar=project-only\n",
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);

    // Both keys from each file are present.
    assert!(entries.iter().any(|(k, v)| k == "foo" && v == "user-only"));
    assert!(
        entries
            .iter()
            .any(|(k, v)| k == "bar" && v == "project-only")
    );

    // The shared key appears twice, in the right order.
    let positions: Vec<_> = entries
        .iter()
        .filter(|(k, _)| k == "auto-install-peers")
        .map(|(_, v)| v.as_str())
        .collect();
    assert_eq!(
        positions.len(),
        2,
        "expected both user and project entries for shared key: {entries:?}"
    );
    assert_eq!(
        positions[0], "true",
        "user entry must come first (precedence is last-write-wins downstream)"
    );
    assert_eq!(
        positions[1], "false",
        "project entry must come second so it overrides the user entry"
    );
}

#[test]
fn pnpm_global_auth_ini_loads_and_overrides_user_rc() {
    // `~/.config/pnpm/auth.ini` is pnpm's out-of-band credential
    // file. Aube needs to read it so users who stash tokens there
    // (to keep them out of `~/.npmrc`) don't get "401 Unauthorized"
    // on a fresh clone. It should beat `~/.npmrc` for the same
    // key, since the entire reason to use it is to override
    // whatever npm-side tooling writes to `.npmrc`.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();

    std::fs::write(
        home_dir.path().join(".npmrc"),
        "//registry.example.com/:_authToken=stale-npmrc\n",
    )
    .unwrap();
    let auth_ini = home_dir.path().join(".config/pnpm/auth.ini");
    std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
    std::fs::write(
        &auth_ini,
        "//registry.example.com/:_authToken=fresh-auth-ini\n\
             //other.example.com/:_authToken=other-token\n",
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    let mut cfg = NpmConfig::default();
    cfg.apply(entries);
    assert_eq!(
        cfg.auth_token_for("https://registry.example.com/"),
        Some("fresh-auth-ini"),
        "auth.ini token should override stale ~/.npmrc token",
    );
    assert_eq!(
        cfg.auth_token_for("https://other.example.com/"),
        Some("other-token"),
        "additional auth.ini entries should be picked up",
    );
}

#[test]
fn pnpm_global_auth_ini_honors_xdg_config_home_override() {
    // When `XDG_CONFIG_HOME` is set, pnpm reads
    // `$XDG_CONFIG_HOME/pnpm/auth.ini` instead of
    // `$HOME/.config/pnpm/auth.ini`. Aube must match, or a user
    // with a custom XDG layout will see pnpm and aube disagree on
    // where credentials live. The injected override here is the
    // same value `load_npmrc_entries` reads from the real env var.
    let home_dir = tempfile::tempdir().unwrap();
    let xdg_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();

    let auth_ini = xdg_dir.path().join("pnpm/auth.ini");
    std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
    std::fs::write(&auth_ini, "//registry.example.com/:_authToken=xdg-token\n").unwrap();
    // Decoy at the default `$HOME/.config/pnpm/auth.ini` location
    // to prove the XDG override replaces the fallback instead of
    // being merged alongside it.
    let decoy = home_dir.path().join(".config/pnpm/auth.ini");
    std::fs::create_dir_all(decoy.parent().unwrap()).unwrap();
    std::fs::write(&decoy, "//registry.example.com/:_authToken=decoy\n").unwrap();

    let entries = load_npmrc_entries_with_home(
        Some(home_dir.path()),
        Some(xdg_dir.path()),
        proj_dir.path(),
        None,
    );
    let mut cfg = NpmConfig::default();
    cfg.apply(entries);
    assert_eq!(
        cfg.auth_token_for("https://registry.example.com/"),
        Some("xdg-token"),
    );
}

#[test]
fn pnpm_global_auth_ini_loses_to_project_npmrc() {
    // Project `.npmrc` pins still win — per-repo configuration is
    // the most specific layer, and a user's global auth.ini
    // must not clobber a token a project explicitly set.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();

    let auth_ini = home_dir.path().join(".config/pnpm/auth.ini");
    std::fs::create_dir_all(auth_ini.parent().unwrap()).unwrap();
    std::fs::write(
        &auth_ini,
        "//registry.example.com/:_authToken=global-auth-ini\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        "//registry.example.com/:_authToken=project-pin\n",
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    let mut cfg = NpmConfig::default();
    cfg.apply(entries);
    assert_eq!(
        cfg.auth_token_for("https://registry.example.com/"),
        Some("project-pin"),
    );
}

#[test]
fn npmrc_auth_file_overrides_user_token() {
    // The whole point of `npmrcAuthFile`: a token declared in the
    // out-of-tree auth file must beat the same token in `~/.npmrc`,
    // so CI can mount a secret-bearing file at a fixed path and
    // know it wins regardless of any leftover entries in user rc.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();
    let auth_file = proj_dir.path().join("auth.npmrc");

    std::fs::write(
        home_dir.path().join(".npmrc"),
        "//registry.example.com/:_authToken=stale-user-token\n",
    )
    .unwrap();
    std::fs::write(
        &auth_file,
        "//registry.example.com/:_authToken=fresh-from-auth-file\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        format!("npmrc-auth-file={}\n", auth_file.display()),
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    let mut cfg = NpmConfig::default();
    cfg.apply(entries);
    assert_eq!(
        cfg.auth_token_for("https://registry.example.com/"),
        Some("fresh-from-auth-file"),
    );
}

#[test]
fn npmrc_auth_file_resolves_relative_to_project_root() {
    // A relative `npmrc-auth-file` path should resolve against the
    // project root, NOT the cwd of the test runner — same convention
    // as the storeDir setting.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(proj_dir.path().join("secrets")).unwrap();
    std::fs::write(
        proj_dir.path().join("secrets/npm"),
        "//registry.example.com/:_authToken=relative-path-token\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        "npmrc-auth-file=secrets/npm\n",
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    assert!(
        entries
            .iter()
            .any(|(k, v)| k == "//registry.example.com/:_authToken" && v == "relative-path-token"),
        "auth file entries missing — got {entries:?}",
    );
}

#[test]
fn npmrc_auth_file_camel_case_alias_works() {
    // The kebab-case spelling is exercised by the other tests; pin
    // the camelCase alias separately so a future tweak to the
    // `matches!` arm can't silently drop one of the spellings.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();
    let auth_file = proj_dir.path().join("auth.npmrc");

    std::fs::write(
        &auth_file,
        "//registry.example.com/:_authToken=camel-token\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        format!("npmrcAuthFile={}\n", auth_file.display()),
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    assert!(
        entries
            .iter()
            .any(|(k, v)| k == "//registry.example.com/:_authToken" && v == "camel-token"),
        "camelCase alias did not load auth file — got {entries:?}",
    );
}

#[test]
fn npmrc_auth_file_expands_tilde_against_home() {
    // `~/secrets/npm` should expand to `<home>/secrets/npm`, mirroring
    // the storeDir / pnpm convention.
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();
    std::fs::create_dir_all(home_dir.path().join("secrets")).unwrap();
    std::fs::write(
        home_dir.path().join("secrets/npm"),
        "//registry.example.com/:_authToken=tilde-token\n",
    )
    .unwrap();
    std::fs::write(
        proj_dir.path().join(".npmrc"),
        "npmrc-auth-file=~/secrets/npm\n",
    )
    .unwrap();

    let entries = load_npmrc_entries_with_home(Some(home_dir.path()), None, proj_dir.path(), None);
    assert!(
        entries
            .iter()
            .any(|(k, v)| k == "//registry.example.com/:_authToken" && v == "tilde-token"),
        "tilde expansion failed — got {entries:?}",
    );
}

#[test]
fn userconfig_override_replaces_default_user_npmrc() {
    // `NPM_CONFIG_USERCONFIG` moves the user rc off the default
    // `$HOME/.npmrc` (XDG setups, CI secret mounts, etc.). When
    // the override is set, the default path must be skipped
    // entirely — matching npm/pnpm, which treat the env var as
    // "this is the user rc," not "also read it on top of the
    // default."
    let home_dir = tempfile::tempdir().unwrap();
    let proj_dir = tempfile::tempdir().unwrap();
    let override_dir = tempfile::tempdir().unwrap();
    let override_rc = override_dir.path().join("npmrc");

    // Decoy at the default location — must NOT be loaded.
    std::fs::write(
        home_dir.path().join(".npmrc"),
        "registry=https://decoy.example/\n",
    )
    .unwrap();
    std::fs::write(&override_rc, "registry=https://override.example/\n").unwrap();

    let entries = load_npmrc_entries_with_home(
        Some(home_dir.path()),
        None,
        proj_dir.path(),
        Some(&override_rc),
    );
    assert!(
        entries
            .iter()
            .any(|(k, v)| k == "registry" && v == "https://override.example/"),
        "override file was not loaded — got {entries:?}",
    );
    assert!(
        !entries.iter().any(|(_, v)| v == "https://decoy.example/"),
        "default ~/.npmrc must be skipped when override is set — got {entries:?}",
    );
}

#[test]
fn expand_userconfig_path_handles_tilde_absolute_and_empty() {
    let home = PathBuf::from("/fake/home");
    assert_eq!(
        expand_userconfig_path("~/config/npm/npmrc", Some(&home)),
        Some(PathBuf::from("/fake/home/config/npm/npmrc"))
    );
    assert_eq!(
        expand_userconfig_path("~", Some(&home)),
        Some(PathBuf::from("/fake/home"))
    );
    // Absolute paths pass through unchanged; tilde without a home
    // can't resolve, so callers see `None` and skip the load.
    assert_eq!(
        expand_userconfig_path("/etc/npmrc", Some(&home)),
        Some(PathBuf::from("/etc/npmrc"))
    );
    assert_eq!(expand_userconfig_path("~/x", None), None);
    // Trimmed-empty values are rejected so an accidentally-empty
    // export doesn't probe the process cwd.
    assert_eq!(expand_userconfig_path("", Some(&home)), None);
    assert_eq!(expand_userconfig_path("   ", Some(&home)), None);
}

#[test]
fn userconfig_override_from_env_prefers_screaming_casing() {
    // npm documents both `NPM_CONFIG_USERCONFIG` and the
    // lowercase form. We match on either so a shell that exports
    // the lowercase variant (direnv, mise, etc.) still relocates
    // the user rc.
    let home = PathBuf::from("/h");
    let upper = vec![(
        "NPM_CONFIG_USERCONFIG".to_string(),
        "/tmp/upper-rc".to_string(),
    )];
    assert_eq!(
        userconfig_override_from_env(&upper, Some(&home)),
        Some(PathBuf::from("/tmp/upper-rc"))
    );
    let lower = vec![(
        "npm_config_userconfig".to_string(),
        "/tmp/lower-rc".to_string(),
    )];
    assert_eq!(
        userconfig_override_from_env(&lower, Some(&home)),
        Some(PathBuf::from("/tmp/lower-rc"))
    );
    // Both set → the SCREAMING form wins regardless of slice
    // position. Positional ordering can't be the tiebreaker
    // because the production caller builds the slice from
    // `std::env::vars()`, which iterates in HashMap order.
    // Explicit casing precedence keeps the two public entry
    // points (`load_npmrc_entries` and `NpmConfig::load_with_env`)
    // from resolving to different files on the same host.
    let upper_first = vec![
        (
            "NPM_CONFIG_USERCONFIG".to_string(),
            "/tmp/upper".to_string(),
        ),
        (
            "npm_config_userconfig".to_string(),
            "/tmp/lower".to_string(),
        ),
    ];
    assert_eq!(
        userconfig_override_from_env(&upper_first, Some(&home)),
        Some(PathBuf::from("/tmp/upper")),
    );
    // Lowercase appearing first must not change the outcome.
    let lower_first = vec![
        (
            "npm_config_userconfig".to_string(),
            "/tmp/lower".to_string(),
        ),
        (
            "NPM_CONFIG_USERCONFIG".to_string(),
            "/tmp/upper".to_string(),
        ),
    ];
    assert_eq!(
        userconfig_override_from_env(&lower_first, Some(&home)),
        Some(PathBuf::from("/tmp/upper")),
        "SCREAMING form must win regardless of slice position",
    );
    // Nothing userconfig-shaped in the env → no override.
    let none_case = vec![("HOME".to_string(), "/h".to_string())];
    assert_eq!(userconfig_override_from_env(&none_case, Some(&home)), None);
}

#[test]
fn load_with_env_honors_npm_config_userconfig() {
    // End-to-end: set `NPM_CONFIG_USERCONFIG` in the captured env
    // slice and a token only present in the overridden file
    // should reach `auth_token_for`. Uses a test-specific host so
    // the developer's real `~/.npmrc` can't plausibly carry the
    // same key and skew the assertion.
    let proj_dir = tempfile::tempdir().unwrap();
    let override_dir = tempfile::tempdir().unwrap();
    let override_rc = override_dir.path().join("custom-npmrc");
    std::fs::write(
        &override_rc,
        "//userconfig-test.example/:_authToken=from-userconfig-file\n",
    )
    .unwrap();
    let env = vec![(
        "NPM_CONFIG_USERCONFIG".to_string(),
        override_rc.display().to_string(),
    )];
    let config = NpmConfig::load_with_env(proj_dir.path(), &env);
    assert_eq!(
        config.auth_token_for("https://userconfig-test.example/"),
        Some("from-userconfig-file"),
    );
}

#[test]
fn fetch_policy_default_matches_settings_toml_declared_defaults() {
    // `settings.toml` declares these defaults; `FetchPolicy::default`
    // must match them verbatim so callers that skip
    // `FetchPolicy::from_ctx` still get the same behavior.
    let p = FetchPolicy::default();
    assert_eq!(p.timeout_ms, 300_000);
    assert_eq!(p.retries, 2);
    assert_eq!(p.retry_factor, 10);
    assert_eq!(p.retry_min_timeout_ms, 10_000);
    assert_eq!(p.retry_max_timeout_ms, 60_000);
}

#[test]
fn fetch_policy_backoff_sequence_matches_make_fetch_happen() {
    // Defaults: min=10s, factor=10, max=60s. Sequence:
    //   attempt 1 → 10s  (10 * 10^0 = 10)
    //   attempt 2 → 60s  (10 * 10^1 = 100 → clamped to 60)
    //   attempt 3 → 60s  (10 * 10^2 = 1000 → clamped to 60)
    let p = FetchPolicy::default();
    assert_eq!(
        p.backoff_for_attempt(1),
        std::time::Duration::from_millis(10_000)
    );
    assert_eq!(
        p.backoff_for_attempt(2),
        std::time::Duration::from_millis(60_000)
    );
    assert_eq!(
        p.backoff_for_attempt(3),
        std::time::Duration::from_millis(60_000)
    );
}

#[test]
fn fetch_policy_backoff_clamps_on_huge_factor() {
    // Saturating math: even `factor=u32::MAX` doesn't panic; the
    // first retry hits the max ceiling and stays there.
    let p = FetchPolicy {
        timeout_ms: 60_000,
        retries: 5,
        retry_factor: u32::MAX,
        retry_min_timeout_ms: 100,
        retry_max_timeout_ms: 5_000,
        ..FetchPolicy::default()
    };
    assert_eq!(
        p.backoff_for_attempt(1),
        std::time::Duration::from_millis(100),
        "first attempt is the min (no multiplier applied yet)",
    );
    assert_eq!(
        p.backoff_for_attempt(2),
        std::time::Duration::from_millis(5_000),
    );
    assert_eq!(
        p.backoff_for_attempt(10),
        std::time::Duration::from_millis(5_000),
        "deep retries still clamp; no overflow panic",
    );
}

#[test]
fn fetch_policy_from_ctx_reads_npmrc_overrides() {
    // Full precedence chain is tested in `aube_settings`; this test
    // just proves the composite struct wires each field through to
    // the right generated accessor.
    let entries = vec![
        ("fetch-timeout".to_string(), "1234".to_string()),
        ("fetch-retries".to_string(), "5".to_string()),
        ("fetch-retry-factor".to_string(), "3".to_string()),
        ("fetch-retry-mintimeout".to_string(), "250".to_string()),
        ("fetch-retry-maxtimeout".to_string(), "9_999".to_string()),
    ];
    let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
        std::collections::BTreeMap::new();
    let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
    let p = FetchPolicy::from_ctx(&ctx);
    assert_eq!(p.timeout_ms, 1234);
    assert_eq!(p.retries, 5);
    assert_eq!(p.retry_factor, 3);
    assert_eq!(p.retry_min_timeout_ms, 250);
    // `9_999` with the underscore doesn't parse as u64 under the
    // generic `str::parse`; the accessor falls through to the
    // declared default. Assert that to lock the behavior.
    assert_eq!(p.retry_max_timeout_ms, 60_000);
}

#[test]
fn fetch_policy_from_ctx_reads_warn_timeout_and_min_speed() {
    // Pin the wiring for the two observability knobs. `from_ctx`
    // must route each through its generated accessor or a later
    // rename in the build script will silently fall back to the
    // declared default.
    let entries = vec![
        ("fetchWarnTimeoutMs".to_string(), "500".to_string()),
        ("fetchMinSpeedKiBps".to_string(), "123".to_string()),
    ];
    let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
        std::collections::BTreeMap::new();
    let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
    let p = FetchPolicy::from_ctx(&ctx);
    assert_eq!(p.warn_timeout_ms, 500);
    assert_eq!(p.min_speed_kibps, 123);
}

#[test]
fn fetch_policy_default_includes_observability_thresholds() {
    // Regression lock: the `settings.toml` defaults for the two
    // observability knobs (10s warn threshold, 50 KiB/s floor) must
    // remain reflected in `FetchPolicy::default()` so callers that
    // skip `from_ctx` still behave like a default-configured pnpm.
    let p = FetchPolicy::default();
    assert_eq!(p.warn_timeout_ms, 10_000);
    assert_eq!(p.min_speed_kibps, 50);
}

#[test]
fn translate_npm_config_env_maps_default_registry() {
    // Both the lowercase and SCREAMING_SNAKE spellings must land
    // on the canonical `.npmrc` key `registry`. The docs promise
    // `NPM_CONFIG_REGISTRY aube install` works; this is the hook
    // that makes it true.
    assert_eq!(
        translate_npm_config_env("NPM_CONFIG_REGISTRY", "https://r.example/"),
        Some(("registry".to_string(), "https://r.example/".to_string()))
    );
    assert_eq!(
        translate_npm_config_env("npm_config_registry", "https://r.example/"),
        Some(("registry".to_string(), "https://r.example/".to_string()))
    );
    // Non-npm env vars are ignored so the entry list stays tight
    // and `apply` isn't fed noise.
    assert_eq!(translate_npm_config_env("HOME", "/tmp"), None);
}

#[test]
fn translate_npm_config_env_maps_proxy_and_tls_knobs() {
    // Multi-word env suffix → hyphenated `.npmrc` key. Pins the
    // mapping for every registry-client knob that's exposed via
    // an env alias so future regressions show up as test
    // failures, not silent drops.
    let cases = [
        ("NPM_CONFIG_HTTPS_PROXY", "http://p:8", "https-proxy"),
        ("NPM_CONFIG_HTTP_PROXY", "http://p:9", "http-proxy"),
        ("NPM_CONFIG_PROXY", "http://p:0", "proxy"),
        ("NPM_CONFIG_NOPROXY", "localhost,.internal", "noproxy"),
        ("NPM_CONFIG_STRICT_SSL", "false", "strict-ssl"),
        ("NPM_CONFIG_LOCAL_ADDRESS", "127.0.0.1", "local-address"),
        ("NPM_CONFIG_MAXSOCKETS", "16", "maxsockets"),
    ];
    for (name, value, expected_key) in cases {
        assert_eq!(
            translate_npm_config_env(name, value),
            Some((expected_key.to_string(), value.to_string())),
            "mapping failed for {name}"
        );
    }
}

#[test]
fn translate_npm_config_env_maps_scoped_registry() {
    // `NPM_CONFIG_@MYORG:REGISTRY` should normalise to the
    // lowercase canonical `@myorg:registry` key that `apply`
    // matches via `strip_suffix(":registry")`.
    assert_eq!(
        translate_npm_config_env("NPM_CONFIG_@MYORG:REGISTRY", "https://r.mycorp/"),
        Some((
            "@myorg:registry".to_string(),
            "https://r.mycorp/".to_string()
        ))
    );
    assert_eq!(
        translate_npm_config_env("npm_config_@myorg:registry", "https://r.mycorp/"),
        Some((
            "@myorg:registry".to_string(),
            "https://r.mycorp/".to_string()
        ))
    );
}

#[test]
fn translate_npm_config_env_passes_uri_auth_through_verbatim() {
    // Per-URI auth keys carry `.npmrc` syntax in the env name.
    // Passthrough preserves the `_authToken` casing that `apply`
    // matches inside its `starts_with("//")` branch.
    assert_eq!(
        translate_npm_config_env(
            "NPM_CONFIG_//registry.example.com/:_authToken",
            "secret-token"
        ),
        Some((
            "//registry.example.com/:_authToken".to_string(),
            "secret-token".to_string()
        ))
    );
}

#[test]
fn load_with_env_npm_config_registry_overrides_project_file() {
    // Integration-ish: `load_with_env` stitches file config and
    // env together. Project `.npmrc` sets one registry URL; the
    // captured env carries `NPM_CONFIG_REGISTRY` with another.
    // The env value must win so the code path a user exercises
    // with `NPM_CONFIG_REGISTRY=... aube install` really does
    // route traffic to the configured host.
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(
        dir.path().join(".npmrc"),
        "registry=https://file.registry.example/\n",
    )
    .unwrap();
    let env = vec![(
        "NPM_CONFIG_REGISTRY".to_string(),
        "https://env.registry.example/".to_string(),
    )];
    let config = NpmConfig::load_with_env(dir.path(), &env);
    assert_eq!(config.registry, "https://env.registry.example/");
}

#[test]
fn env_registry_overrides_project_npmrc() {
    // End-to-end: `apply` consumes the synthesised env entry last,
    // so a `NPM_CONFIG_REGISTRY` value beats whatever the project
    // `.npmrc` declares. This is the behaviour the user-facing
    // docs (`docs/package-manager/configuration.md`) guarantee.
    //
    // Driven through `apply` directly to avoid racing other tests
    // on the process-wide env (edition 2024 requires `unsafe` for
    // `set_var`, and the test harness runs cases in parallel).
    let mut config = NpmConfig {
        registry: "https://registry.npmjs.org/".to_string(),
        ..Default::default()
    };
    config.apply(vec![(
        "registry".to_string(),
        "https://file.registry/".to_string(),
    )]);
    assert_eq!(config.registry, "https://file.registry/");
    // Emulate the `load_npm_config_env_entries` output for
    // `NPM_CONFIG_REGISTRY=https://env.registry/`.
    let env = translate_npm_config_env("NPM_CONFIG_REGISTRY", "https://env.registry/")
        .map(|e| vec![e])
        .unwrap_or_default();
    config.apply(env);
    assert_eq!(
        config.registry, "https://env.registry/",
        "env var must override file-based registry"
    );
}

#[test]
fn is_public_npmjs_matches_canonical_and_normalised_urls() {
    // The same npmjs.org URL spelled different ways should all
    // resolve to "yes, public" — supply-chain gates skip on a
    // mismatch and we don't want capitalization / trailing slash
    // drift between npm, pnpm, and aube's own normalisation to
    // accidentally flip a public package into "private" mode.
    for url in [
        "https://registry.npmjs.org/",
        "https://registry.npmjs.org",
        "https://Registry.NPMJS.org/",
        "http://registry.npmjs.org/",
        "//registry.npmjs.org/",
        // URI schemes are case-insensitive per RFC 3986. A
        // user-typed `HTTPS://...` in `.npmrc` must not silently
        // fall through and disable the supply-chain gates.
        "HTTPS://registry.npmjs.org/",
        "Http://registry.npmjs.org/",
    ] {
        assert!(is_public_npmjs_url(url), "expected public for {url}");
    }
}

#[test]
fn is_public_npmjs_rejects_private_and_mirror_urls() {
    // Anything other than the canonical npmjs host counts as
    // private from the gate's perspective: mirrors, internal
    // Verdaccio installs, Artifactory proxies, and unrelated
    // hosts that happen to embed `npmjs.org` as a subpath.
    for url in [
        "https://internal.example.com/",
        "https://npm.pkg.github.com/",
        "https://registry.yarnpkg.com/",
        "https://example.com/registry.npmjs.org/",
        "ftp://registry.npmjs.org/",
    ] {
        assert!(!is_public_npmjs_url(url), "expected private for {url}");
    }
}

#[test]
fn is_public_npmjs_does_not_panic_on_multibyte_char_at_prefix_boundary() {
    // `https:/ñ...` — the `ñ` straddles byte offset 8 (the
    // length of `"https://"`). A naive `split_at(8)` would
    // panic; the helper has to use `split_at_checked` and
    // return `None` so `aube add` doesn't crash on a typo'd
    // `.npmrc` value.
    assert!(!is_public_npmjs_url("https:/ñregistry.npmjs.org/"));
    // Also exercise a multi-byte char inside what looks like a
    // valid scheme — we should reject this as non-public
    // without crashing.
    assert!(!is_public_npmjs_url("htñps://registry.npmjs.org/"));
}

#[test]
fn is_public_npmjs_via_npm_config_uses_scope_override() {
    // A scoped registry override flips the per-package answer
    // even though the default registry is still npmjs. Verifies
    // the gate filter respects scope→registry mapping rather
    // than only looking at the global `registry=` field.
    let mut cfg = NpmConfig {
        registry: "https://registry.npmjs.org/".to_string(),
        ..Default::default()
    };
    cfg.scoped_registries.insert(
        "@myorg".to_string(),
        "https://npm.internal.example/".to_string(),
    );
    assert!(cfg.is_public_npmjs("lodash"));
    assert!(!cfg.is_public_npmjs("@myorg/utils"));
}

#[test]
fn fetch_policy_clamps_giant_retry_counts_into_u32() {
    // A user writing `fetch-retries=99999999999` should not panic;
    // the retry loop just caps at u32::MAX attempts.
    let entries = vec![("fetch-retries".to_string(), "99999999999999".to_string())];
    let ws: std::collections::BTreeMap<String, yaml_serde::Value> =
        std::collections::BTreeMap::new();
    let ctx = aube_settings::ResolveCtx::files_only(&entries, &ws);
    let p = FetchPolicy::from_ctx(&ctx);
    assert_eq!(p.retries, u32::MAX);
}