diff --git a/pacquet/crates/cli/tests/pnpr_install.rs b/pacquet/crates/cli/tests/pnpr_install.rs index 8278c15d60..387e6f05ed 100644 --- a/pacquet/crates/cli/tests/pnpr_install.rs +++ b/pacquet/crates/cli/tests/pnpr_install.rs @@ -13,6 +13,7 @@ use pacquet_testing_utils::{ bin::{AddMockedRegistry, CommandTempCwd}, fs::is_symlink_or_junction, }; +use pnpr::TokenBackend; use std::{ fs, net::{Ipv4Addr, SocketAddr, TcpListener, TcpStream}, @@ -23,8 +24,8 @@ use std::{ }; /// Start an in-process pnpr with the fast-path endpoints on a detached -/// thread; returns its base URL. -fn start_pnpr() -> String { +/// thread; returns its base URL and a pre-seeded bearer token. +fn start_pnpr() -> (String, String) { // Persisted (not cleaned) because the detached server thread outlives // this function. let storage = tempfile::tempdir().expect("pnpr storage").keep(); @@ -32,6 +33,15 @@ fn start_pnpr() -> String { // tokio's `from_std` requires the listener to be non-blocking. listener.set_nonblocking(true).expect("set pnpr listener non-blocking"); let addr = listener.local_addr().expect("pnpr addr"); + let tokens_path = storage.join("tokens.db"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("token setup runtime"); + let token = runtime.block_on(async { + let tokens = pnpr::TokenStore::open(tokens_path.clone()).expect("open token store"); + tokens.issue("pacquet-test").await.expect("issue pnpr test token") + }); thread::Builder::new() .name("pnpr".to_string()) @@ -43,6 +53,7 @@ fn start_pnpr() -> String { runtime.block_on(async move { let mut config = pnpr::Config::proxy(addr, storage); config.public_url = format!("http://{addr}"); + config.auth.tokens.file = Some(tokens_path); let listener = tokio::net::TcpListener::from_std(listener).expect("tokio listener"); let _ = pnpr::serve_listener(config, listener).await; }); @@ -50,7 +61,16 @@ fn start_pnpr() -> String { .expect("spawn pnpr thread"); wait_until_ready(addr); - format!("http://{addr}/") + (format!("http://{addr}/"), token) +} + +fn configure_pnpr_auth(npmrc_path: &std::path::Path, pnpr_url: &str, token: &str) { + let authority = + pnpr_url.strip_prefix("http://").expect("test pnpr URL uses http").trim_end_matches('/'); + let current = fs::read_to_string(npmrc_path).expect("read .npmrc"); + let separator = if current.ends_with('\n') { "" } else { "\n" }; + fs::write(npmrc_path, format!("{current}{separator}//{authority}/:_authToken={token}\n")) + .expect("write pnpr auth to .npmrc"); } fn wait_until_ready(addr: SocketAddr) { @@ -71,9 +91,10 @@ fn pacquet_at(workspace: &Path) -> Command { fn install_via_pnpr_links_node_modules() { let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = CommandTempCwd::init().add_mocked_registry(); - let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info; + let AddMockedRegistry { npmrc_path, store_dir, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -81,7 +102,13 @@ fn install_via_pnpr_links_node_modules() { }); fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); - pacquet.with_arg("install").with_arg("--pnpr-server").with_arg(&pnpr_url).assert().success(); + pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) + .with_arg("install") + .with_arg("--pnpr-server") + .with_arg(&pnpr_url) + .assert() + .success(); let symlink_path = workspace.join("node_modules/@foo/no-deps"); assert!(is_symlink_or_junction(&symlink_path).unwrap(), "direct dep should be symlinked"); @@ -101,7 +128,8 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo CommandTempCwd::init().add_mocked_registry(); let AddMockedRegistry { npmrc_path, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -109,7 +137,13 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo }); fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); - pacquet.with_arg("install").with_arg("--pnpr-server").with_arg(&pnpr_url).assert().success(); + pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) + .with_arg("install") + .with_arg("--pnpr-server") + .with_arg(&pnpr_url) + .assert() + .success(); fs::remove_dir_all(workspace.join("node_modules")).expect("remove node_modules"); let mut verifier = mockito::Server::new(); @@ -162,9 +196,10 @@ fn frozen_install_via_pnpr_verifies_the_local_lockfile_without_resolving_or_redo fn install_via_pnpr_lockfile_only_writes_lockfile_without_linking() { let CommandTempCwd { pacquet, root, workspace, npmrc_info, .. } = CommandTempCwd::init().add_mocked_registry(); - let AddMockedRegistry { store_dir, mock_instance, .. } = npmrc_info; + let AddMockedRegistry { npmrc_path, store_dir, mock_instance, .. } = npmrc_info; - let pnpr_url = start_pnpr(); + let (pnpr_url, token) = start_pnpr(); + configure_pnpr_auth(&npmrc_path, &pnpr_url, &token); let manifest_path = workspace.join("package.json"); let package_json = serde_json::json!({ @@ -173,6 +208,7 @@ fn install_via_pnpr_lockfile_only_writes_lockfile_without_linking() { fs::write(&manifest_path, package_json.to_string()).expect("write package.json"); pacquet + .with_env("PNPM_CONFIG_REGISTRY", mock_instance.url()) .with_arg("install") .with_arg("--pnpr-server") .with_arg(&pnpr_url) diff --git a/pacquet/crates/pnpr-client/tests/integration.rs b/pacquet/crates/pnpr-client/tests/integration.rs index fb14f24295..fc0decf10e 100644 --- a/pacquet/crates/pnpr-client/tests/integration.rs +++ b/pacquet/crates/pnpr-client/tests/integration.rs @@ -20,6 +20,9 @@ use pacquet_testing_utils::registry::TestRegistry; use tempfile::TempDir; use tokio::net::TcpListener; +/// Basic auth for `pnpr-client:password123`, registered by [`start_pnpr`]. +const PNPR_AUTHORIZATION: &str = "Basic cG5wci1jbGllbnQ6cGFzc3dvcmQxMjM="; + /// Start an in-process pnpr with the fast-path endpoints. Returns the /// base URL and the storage guard. async fn start_pnpr() -> (String, TempDir) { @@ -29,13 +32,16 @@ async fn start_pnpr() -> (String, TempDir) { let mut config = pnpr::Config::proxy(addr, storage.path().to_path_buf()); config.public_url = format!("http://{addr}"); + config.auth.htpasswd.max_users = pnpr::MaxUsers::Unlimited; tokio::spawn(async move { let _ = pnpr::serve_listener(config, listener).await; }); wait_until_ready(addr).await; - (format!("http://{addr}/"), storage) + let base_url = format!("http://{addr}/"); + let _ = register_token(&base_url, "pnpr-client").await; + (base_url, storage) } async fn wait_until_ready(addr: SocketAddr) { @@ -70,8 +76,9 @@ fn nerf_key(url: &str) -> String { if path.is_empty() { format!("//{authority}/") } else { format!("//{authority}/{path}/") } } -/// Register a user with the shared test registry and return its bearer -/// token, so a test can forward it as the caller's upstream credential. +/// Register a user with an npm-compatible registry and return its bearer +/// token. The pnpr fixture reuses the account through Basic auth; registry +/// tests forward the token as an upstream credential. async fn register_token(registry_url: &str, username: &str) -> String { let body = serde_json::json!({ "name": username, "password": "password123" }); let response = reqwest::Client::new() @@ -93,7 +100,7 @@ fn options(registry: &str, dependencies: BTreeMap) -> ResolveOpt registry: registry.to_string(), named_registries: BTreeMap::new(), auth_headers: BTreeMap::new(), - authorization: None, + authorization: Some(PNPR_AUTHORIZATION.to_string()), overrides: None, lockfile: None, frozen_lockfile: false, diff --git a/pacquet/tasks/integrated-benchmark/src/work_env.rs b/pacquet/tasks/integrated-benchmark/src/work_env.rs index 28a00eb0a1..21683fd9cc 100644 --- a/pacquet/tasks/integrated-benchmark/src/work_env.rs +++ b/pacquet/tasks/integrated-benchmark/src/work_env.rs @@ -18,7 +18,7 @@ use std::{ borrow::Cow, collections::HashMap, fmt::{self, Write as _}, - fs::{self, File}, + fs::{self, File, OpenOptions}, io::Write, net::{Ipv4Addr, SocketAddr, TcpStream}, path::{Path, PathBuf}, @@ -33,6 +33,9 @@ const BENCHMARK_DIAGNOSTICS_MD: &str = "BENCHMARK_DIAGNOSTICS.md"; const PNPR_DIRECT_RATIO_MAX: f64 = 1.05; const PNPR_SERVER_REGISTRY_ENV: &str = "PACQUET_BENCHMARK_PNPR_SERVER_REGISTRY"; const PNPR_TARBALL_REWRITE_FROM_ENV: &str = "PACQUET_BENCHMARK_PNPR_TARBALL_REWRITE_FROM"; +const PNPR_BENCHMARK_AUTH_PAIR_BASE64: &str = "cG5wci1iZW5jaG1hcms6cGFzc3dvcmQxMjM="; +const PNPR_BENCHMARK_HTPASSWD: &str = + "pnpr-benchmark:$2y$04$MpoezfOJSlhn9S4iiOJihue1IMZTZfYclKbajdz.Dt2pvAoBLNAay\n"; #[derive(Debug)] pub struct WorkEnv { @@ -501,6 +504,8 @@ impl WorkEnv { binary.is_file(), "pnpr binary not found at {binary:?} — the build step did not produce it", ); + let pnpr_storage = bench_dir.join("pnpr-storage"); + seed_pnpr_auth(&pnpr_storage); let port = pick_unused_port().expect("pick an unused port for the pnpr server"); eprintln!("Starting pnpr server for {id} on 127.0.0.1:{port}..."); @@ -512,7 +517,7 @@ impl WorkEnv { .arg("--listen") .arg(format!("127.0.0.1:{port}")) .arg("--storage") - .arg(bench_dir.join("pnpr-storage")) + .arg(&pnpr_storage) // The resolver resolves against the registry the client // sends, caching packuments in its own store. A long TTL keeps // those cached packuments authoritative across the run, the @@ -571,6 +576,7 @@ impl WorkEnv { // raw upstream tarball URLs even when the server resolves through a // latency proxy; the pacquet client rewrites this prefix to its // configured registry, which is `client_registry` from `.npmrc`. + append_pnpr_auth_to_npmrc(&bench_dir, &client_url); fs::write( bench_dir.join(".pnpr-env"), format!( @@ -1362,6 +1368,34 @@ fn create_npmrc(dir: &Path, registry: &str, scenario: BenchmarkScenario) { writeln!(file, "{}", scenario.npmrc_lockfile_setting()).unwrap(); } +fn seed_pnpr_auth(pnpr_storage: &Path) { + fs::create_dir_all(pnpr_storage).expect("create pnpr storage before seeding auth"); + fs::write(pnpr_storage.join("htpasswd"), PNPR_BENCHMARK_HTPASSWD) + .expect("seed pnpr benchmark htpasswd"); +} + +fn append_pnpr_auth_to_npmrc(dir: &Path, pnpr_server: &str) { + let path = dir.join(".npmrc"); + let mut file = + OpenOptions::new().append(true).open(&path).expect("open benchmark .npmrc for pnpr auth"); + writeln!( + file, + "{}:_auth={}", + pnpr_auth_config_key(pnpr_server), + PNPR_BENCHMARK_AUTH_PAIR_BASE64, + ) + .expect("append pnpr auth to benchmark .npmrc"); +} + +fn pnpr_auth_config_key(pnpr_server: &str) -> String { + let Some(without_scheme) = + pnpr_server.strip_prefix("http://").or_else(|| pnpr_server.strip_prefix("https://")) + else { + panic!("pnpr server URL must include a scheme: {pnpr_server}"); + }; + format!("//{}/", without_scheme.trim_end_matches('/')) +} + fn may_create_lockfile(dst_dir: &Path, scenario: BenchmarkScenario, src_dir: Option<&Path>) { let load_lockfile = || -> Cow<'_, str> { let Some(src_dir) = src_dir else { return Cow::Borrowed(LOCKFILE) }; diff --git a/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs b/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs index 7cde2143c7..a26ffd5a03 100644 --- a/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs +++ b/pacquet/tasks/integrated-benchmark/src/work_env/tests.rs @@ -1,6 +1,6 @@ use super::{ BenchmarkScenario, HyperfineCommand, PhaseEvent, collect_pnpr_direct_ratios, - non_trivial_cold_batch, read_phase_events, render_diagnostics_markdown, + non_trivial_cold_batch, pnpr_auth_config_key, read_phase_events, render_diagnostics_markdown, requires_fresh_pnpr_cold_batch_metrics, summarize_phase_events, }; use std::{collections::HashMap, fs}; @@ -136,6 +136,12 @@ fn cold_batch_metrics_canary_targets_current_pnpr_revision() { assert!(!requires_fresh_pnpr_cold_batch_metrics("pacquet@HEAD")); } +#[test] +fn pnpr_auth_config_key_uses_npmrc_nerf_shape() { + assert_eq!(pnpr_auth_config_key("http://127.0.0.1:42509"), "//127.0.0.1:42509/"); + assert_eq!(pnpr_auth_config_key("http://localhost:4873/pnpr/"), "//localhost:4873/pnpr/"); +} + #[test] fn diagnostics_markdown_includes_create_virtual_store_line_item() { let markdown = render_diagnostics_markdown( diff --git a/pnpm11/__utils__/jest-config/with-registry/globalSetup.js b/pnpm11/__utils__/jest-config/with-registry/globalSetup.js index 478a69260b..4d340ed884 100644 --- a/pnpm11/__utils__/jest-config/with-registry/globalSetup.js +++ b/pnpm11/__utils__/jest-config/with-registry/globalSetup.js @@ -1,5 +1,5 @@ import { spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import { scheduler } from 'node:timers/promises' @@ -26,12 +26,14 @@ export default async () => { const storage = mkdtempSync(path.join(tmpdir(), 'pnpm-registry-mock-storage-')) buildStorage(storage) process.env.PNPM_REGISTRY_MOCK_STORAGE = storage + const config = writeTestConfig(storage) const bin = resolvePnprBin() const server = spawn( bin, [ + '--config', config, '--listen', `127.0.0.1:${process.env.PNPM_REGISTRY_MOCK_PORT}`, '--storage', storage, '--public-url', `http://localhost:${process.env.PNPM_REGISTRY_MOCK_PORT}`, @@ -89,6 +91,18 @@ export default async () => { process.env.REGISTRY_MOCK_TOKEN = token } +function writeTestConfig (storage) { + const source = path.join(REPO_ROOT, 'pnpr', 'crates', 'pnpr', 'config.yaml') + const bundled = readFileSync(source, 'utf8') + const configured = bundled.replace('max_users: -1', 'max_users: 100') + if (configured === bundled) { + throw new Error('pnpr test config could not enable test-only registration') + } + const target = path.join(storage, 'config.yaml') + writeFileSync(target, configured) + return target +} + /** * Build registry storage from the in-repo fixtures into `out` using the * `pnpr-prepare` binary (built from the `pnpr-fixtures` crate). The same diff --git a/pnpm11/pnpm/test/install/pnpmRegistry.ts b/pnpm11/pnpm/test/install/pnpmRegistry.ts index 1823cc9334..8aa02b055d 100644 --- a/pnpm11/pnpm/test/install/pnpmRegistry.ts +++ b/pnpm11/pnpm/test/install/pnpmRegistry.ts @@ -55,8 +55,24 @@ afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())) }) +function configurePnprAuth (): void { + const token = process.env.REGISTRY_MOCK_TOKEN + if (!token) throw new Error('REGISTRY_MOCK_TOKEN is required for pnpr integration tests') + fs.appendFileSync('.npmrc', `//localhost:${serverPort}/:_authToken=${token}\n`) +} + +function prepareProject (manifest: Parameters[0]): void { + prepare(manifest) + configurePnprAuth() +} + +function prepareWorkspace (packages: Parameters[0]): void { + preparePackages(packages) + configurePnprAuth() +} + test('pnpm install uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', }, @@ -79,7 +95,7 @@ test('pnpm install uses pnpr server when configured', async () => { }) test('pnpm install resolves optionalDependencies via the pnpr server', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', }, @@ -102,7 +118,7 @@ test('pnpm install resolves optionalDependencies via the pnpr server', async () }) test('a second resolution forwards the existing lockfile to the pnpr server', async () => { - prepare({}) + prepareProject({}) // First add creates the lockfile. await execPnpm(['add', 'is-positive@1.0.0', `--config.pnprServer=http://localhost:${serverPort}`]) @@ -120,7 +136,7 @@ test('a second resolution forwards the existing lockfile to the pnpr server', as }) test('pnpm add uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-negative': '1.0.0', }, @@ -146,7 +162,7 @@ test('pnpm add uses pnpr server when configured', async () => { }) test('pnpm remove uses pnpr server when configured', async () => { - prepare({ + prepareProject({ dependencies: { 'is-positive': '1.0.0', 'is-negative': '1.0.0', @@ -171,7 +187,7 @@ test('pnpm remove uses pnpr server when configured', async () => { }) test('pnpm add without a version uses the pnpr server and writes the save-prefix spec from the lockfile', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -190,7 +206,7 @@ test('pnpm add without a version uses the pnpr server and writes the save-prefix }) test('pnpm add -D uses pnpr server and targets devDependencies', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -210,7 +226,7 @@ test('pnpm add -D uses pnpr server and targets devDependencies', async () => { }) test('pnpm add with multiple selectors uses pnpr server', async () => { - prepare({}) + prepareProject({}) requestCount = 0 @@ -228,7 +244,7 @@ test('pnpm add with multiple selectors uses pnpr server', async () => { }) test('pnpm --filter remove inside a workspace uses pnpr server', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', @@ -268,7 +284,7 @@ test('pnpm --filter remove inside a workspace uses pnpr server', async () => { }) test('pnpm add inside a workspace project uses pnpr server', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', @@ -303,7 +319,7 @@ test('pnpm add inside a workspace project uses pnpr server', async () => { }) test('pnpm install with pnpr server works in a workspace with multiple projects', async () => { - preparePackages([ + prepareWorkspace([ { name: 'project-a', version: '1.0.0', diff --git a/pnpr/crates/pnpr/config.yaml b/pnpr/crates/pnpr/config.yaml index 2f9fb117c9..1b1bcb4b31 100644 --- a/pnpr/crates/pnpr/config.yaml +++ b/pnpr/crates/pnpr/config.yaml @@ -52,11 +52,9 @@ secret: pnpm-registry-mock-secret-key-32 auth: htpasswd: file: ./htpasswd - # Maximum number of users allowed to self-register. Registration is - # opt-in: it stays disabled unless this is set to a positive number - # (set it to -1 to keep it disabled explicitly). This mock registry - # opts in so the test suite can create accounts. - max_users: 1000 + # Maximum amount of users allowed to register. The safe default is -1 + # (registration disabled); set a non-negative cap to allow registration. + max_users: -1 plugins: ../node_modules diff --git a/pnpr/crates/pnpr/src/auth.rs b/pnpr/crates/pnpr/src/auth.rs index e41c619d6a..8f7f93616f 100644 --- a/pnpr/crates/pnpr/src/auth.rs +++ b/pnpr/crates/pnpr/src/auth.rs @@ -131,16 +131,18 @@ impl std::fmt::Debug for AuthState { } impl AuthState { - /// All-in-memory auth state for surfaces that never expose - /// registration: a resolver-only deployment (registry disabled, so - /// the adduser route is not mounted) and tests that don't care - /// about persistence. Registration is left uncapped here because no - /// untrusted caller can reach it; the production registration path - /// goes through [`Self::load`], which honors `auth.htpasswd.max_users`. + /// All-in-memory auth state with open registration. Used by tests + /// and registry-mock-compatible programmatic routers. #[must_use] pub fn in_memory() -> Self { + Self::in_memory_with_max_users(MaxUsers::Unlimited) + } + + /// All-in-memory auth state that enforces the resolved registration cap. + #[must_use] + pub fn in_memory_with_max_users(max_users: MaxUsers) -> Self { Self { - users: Arc::new(UserStore::in_memory(MaxUsers::Unlimited)), + users: Arc::new(UserStore::in_memory_with_max_users(max_users)), tokens: Arc::new(TokenStore::in_memory()), } } @@ -203,7 +205,7 @@ impl AuthState { } let users: Arc = match auth.htpasswd.file.clone() { Some(path) => Arc::new(UserStore::open(path, auth.htpasswd.max_users)?), - None => Arc::new(UserStore::in_memory(auth.htpasswd.max_users)), + None => Arc::new(UserStore::in_memory_with_max_users(auth.htpasswd.max_users)), }; let tokens: Arc = match auth.tokens.file.clone() { Some(path) => Arc::new(TokenStore::open(path)?), @@ -314,14 +316,16 @@ pub struct UserStore { } impl UserStore { - /// In-memory store with no on-disk persistence. Used when - /// `auth.htpasswd.file` is unset and by the existing - /// `@pnpm/registry-mock` integration where every restart is a - /// fresh process. The registration cap is honored here just as it - /// is for the file-backed store, so an unset `auth.htpasswd.file` - /// does not silently re-open sign-ups that `max_users` denied. + /// In-memory store with no on-disk persistence and open registration. + /// Used by registry-mock-compatible programmatic routers. #[must_use] - pub fn in_memory(max_users: MaxUsers) -> Self { + pub fn in_memory() -> Self { + Self::in_memory_with_max_users(MaxUsers::Unlimited) + } + + /// In-memory store that enforces the resolved registration cap. + #[must_use] + pub fn in_memory_with_max_users(max_users: MaxUsers) -> Self { Self { users: Mutex::new(HashMap::new()), path: None, @@ -624,7 +628,7 @@ impl Default for TokenStore { impl Default for UserStore { fn default() -> Self { - Self::in_memory(MaxUsers::Unlimited) + Self::in_memory() } } diff --git a/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs b/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs index c1dc73f66e..cb0c8eb8e3 100644 --- a/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs +++ b/pnpr/crates/pnpr/src/auth/libsql_backend/tests.rs @@ -31,6 +31,13 @@ async fn add_or_login_rejects_existing_user_with_wrong_password() { assert_eq!(err.status_code(), axum::http::StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn max_users_disabled_rejects_registration() { + let backend = local_backend(MaxUsers::Disabled).await; + let err = backend.add_or_login("alice", "x").await.unwrap_err(); + assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN); +} + #[tokio::test] async fn add_or_login_rejects_invalid_username_before_insert() { let backend = local_backend(MaxUsers::Unlimited).await; diff --git a/pnpr/crates/pnpr/src/auth/tests.rs b/pnpr/crates/pnpr/src/auth/tests.rs index 982b0e5a38..0f9fb49f51 100644 --- a/pnpr/crates/pnpr/src/auth/tests.rs +++ b/pnpr/crates/pnpr/src/auth/tests.rs @@ -240,7 +240,7 @@ async fn max_users_minus_one_disables_registration() { async fn in_memory_store_honors_the_registration_cap() { // An unset `auth.htpasswd.file` must not re-open sign-ups that the // configured cap denied: the in-memory store enforces it too. - let store = UserStore::in_memory(MaxUsers::Disabled); + let store = UserStore::in_memory_with_max_users(MaxUsers::Disabled); let err = store.add_or_login("alice", "secret").await.unwrap_err(); assert_eq!(err.status_code(), axum::http::StatusCode::FORBIDDEN); } diff --git a/pnpr/crates/pnpr/src/config/tests.rs b/pnpr/crates/pnpr/src/config/tests.rs index 07d4ca5d65..e478a11490 100644 --- a/pnpr/crates/pnpr/src/config/tests.rs +++ b/pnpr/crates/pnpr/src/config/tests.rs @@ -443,6 +443,7 @@ fn from_default_yaml_parses_bundled_file() { let config = Config::from_default_yaml(Path::new("/tmp"), listen(), None); assert!(config.uplinks.contains_key("npmjs")); assert_eq!(config.uplinks["npmjs"].url, "https://registry.npmjs.org/"); + assert_eq!(config.auth.htpasswd.max_users, super::MaxUsers::Disabled); // The bundled file routes the catch-all through npmjs. let (name, _) = config.resolve_uplink("lodash").expect("** -> npmjs in defaults"); assert_eq!(name, "npmjs"); @@ -931,7 +932,7 @@ packages: {} } #[test] -fn auth_block_absent_keeps_in_memory_defaults() { +fn auth_block_absent_disables_registration_by_default() { let yaml = "storage: ./s\nuplinks: {}\npackages: {}\n"; let config = Config::from_yaml_str(yaml, Path::new("/x"), listen(), None).unwrap(); assert!(config.auth.htpasswd.file.is_none()); diff --git a/pnpr/crates/pnpr/src/server.rs b/pnpr/crates/pnpr/src/server.rs index 2037cbdbdc..915d23348e 100644 --- a/pnpr/crates/pnpr/src/server.rs +++ b/pnpr/crates/pnpr/src/server.rs @@ -24,7 +24,7 @@ use axum::{ connect_info::Connected, }, http::{HeaderMap, Method, StatusCode, header, request::Parts}, - middleware::Next, + middleware::{self, Next}, response::{IntoResponse, Response}, routing::{any, delete, get, post, put}, serve::IncomingStream, @@ -167,14 +167,16 @@ impl PackageLocks { /// router level, so we take both via one handler that branches on /// the `@` prefix and the literal-`-` segment. pub fn router(config: Config) -> Router { - router_with_auth(config, AuthState::in_memory()) + let max_users = config.auth.htpasswd.max_users; + router_with_auth(config, AuthState::in_memory_with_max_users(max_users)) } /// Fallible counterpart to [`router`]: surfaces a missing/invalid OSV /// database (when `osv.enabled`) as an error instead of panicking, for /// embedders that build the router directly rather than via [`serve`]. pub fn try_router(config: Config) -> crate::error::Result { - try_router_with_auth(config, AuthState::in_memory()) + let max_users = config.auth.htpasswd.max_users; + try_router_with_auth(config, AuthState::in_memory_with_max_users(max_users)) } /// Like [`router`] but with a caller-supplied [`AuthState`]. Used @@ -212,17 +214,14 @@ fn load_active_osv_index( } } -/// Run the registry surface's startup side effects and load its auth -/// backends — but only when the registry is enabled. A resolver-only -/// deployment skips publish-journal recovery and on-disk auth entirely, -/// so it stays stateless: it creates no credential files and reads -/// nothing from the store, and so can run on a read-only or ephemeral -/// host. The resolver routes never consult auth. +/// Run startup side effects and load auth backends for surfaces that +/// consult caller identity. The registry needs publish-journal recovery; +/// both the registry and resolver need auth because resolver requests +/// control outbound dependency-resolution work. async fn load_startup_auth(config: &Config) -> crate::error::Result { - if !config.registry.enabled { - return Ok(AuthState::in_memory()); + if config.registry.enabled { + crate::journal::recover_publish_journal(config).await?; } - crate::journal::recover_publish_journal(config).await?; AuthState::load(&config.auth, &config.backend).await } @@ -282,8 +281,20 @@ fn router_with_auth_and_osv( if resolver_enabled { router = router .route("/-/pnpr", get(serve_pnpr_handshake)) - .route("/-/pnpr/v0/resolve", post(serve_resolve)) - .route("/-/pnpr/v0/verify-lockfile", post(serve_verify_lockfile)); + .route( + "/-/pnpr/v0/resolve", + post(serve_resolve).route_layer(middleware::from_fn_with_state( + state.clone(), + require_resolver_caller, + )), + ) + .route( + "/-/pnpr/v0/verify-lockfile", + post(serve_verify_lockfile).route_layer(middleware::from_fn_with_state( + state.clone(), + require_resolver_caller, + )), + ); } else { router = router.route("/-/pnpr", any(resolver_disabled)); } @@ -374,11 +385,10 @@ fn router_with_auth_and_osv( .with_state(state) } -/// Bind to `config.listen` and serve forever. When the registry surface -/// is enabled, loads its htpasswd users and token database before binding -/// the socket so a startup-time auth error surfaces before we accept any -/// client connections; a resolver-only server skips journal recovery and -/// on-disk auth entirely and stays stateless. +/// Bind to `config.listen` and serve forever. Loads auth state before +/// binding so a startup-time auth error surfaces before we accept any +/// client connections. Registry startup additionally recovers the publish +/// journal. pub async fn serve(config: Config) -> crate::error::Result<()> { // Enforce the "at least one surface" invariant here too, not only at // YAML load / CLI: embedders build `Config` programmatically and call @@ -424,11 +434,9 @@ pub async fn serve_listener( config.ensure_a_feature_is_enabled()?; log_enabled_surfaces(&config); let osv_index = load_active_osv_index(&config)?; - // Load the configured auth backends here too (when the registry is - // enabled) — going through `router` would silently fall back to - // in-memory auth and ignore a persisted htpasswd / SQLite store or a - // configured `backend:`. A resolver-only server intentionally uses - // in-memory auth (see [`load_startup_auth`]). + // Load the configured auth backends here too — going through `router` + // would silently fall back to in-memory auth and ignore a persisted + // htpasswd / SQLite store or a configured `backend:`. let auth = load_startup_auth(&config).await?; let app = router_with_auth_and_osv(config, auth, osv_index); tracing::info!(%listen, "pnpr listening"); @@ -1237,6 +1245,43 @@ fn require_caller(identity: &Identity, resource: &str) -> Result Result, RegistryError> { + let authorization = single_authorization_header(headers)?; + identify(authorization, state.inner.auth.users.as_ref(), state.inner.auth.tokens.as_ref()).await +} + +async fn require_resolver_caller( + State(state): State, + request: Request, + next: Next, +) -> Response { + match caller_username(&state, request.headers()).await { + Ok(Some(_username)) => next.run(request).await, + Ok(None) => error_response(&RegistryError::Unauthenticated { + resource: "dependency resolution".to_string(), + }), + Err(error) => error_response(&error), + } +} + +fn single_authorization_header(headers: &HeaderMap) -> Result, RegistryError> { + let mut values = headers.get_all(header::AUTHORIZATION).iter(); + let Some(value) = values.next() else { + return Ok(None); + }; + if values.next().is_some() { + return Err(RegistryError::BadRequest { + reason: "multiple Authorization headers are not allowed".to_string(), + }); + } + value.to_str().map(Some).map_err(|_| RegistryError::BadRequest { + reason: "Authorization header is not valid text".to_string(), + }) +} + fn json_response(status: StatusCode, body: &Value) -> Response { let bytes = serde_json::to_vec(body).expect("static-shape JSON serializes"); Response::builder() @@ -2068,11 +2113,10 @@ async fn authenticate(State(state): State, mut request: Request, next: // Copy what resolution needs out of the request before mutating its // extensions below — the header and method borrows can't outlive the // `extensions_mut` call. - let header = request - .headers() - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .map(str::to_owned); + let header = match single_authorization_header(request.headers()) { + Ok(header) => header.map(str::to_owned), + Err(err) => return error_response(&err), + }; let method = request.method().clone(); let peer = request.extensions().get::>().map(|info| info.0.0); diff --git a/pnpr/crates/pnpr/tests/auth_persistence.rs b/pnpr/crates/pnpr/tests/auth_persistence.rs index e1e75caf77..d5d4ba942d 100644 --- a/pnpr/crates/pnpr/tests/auth_persistence.rs +++ b/pnpr/crates/pnpr/tests/auth_persistence.rs @@ -10,7 +10,7 @@ use axum::{ http::{Request, StatusCode}, }; use pnpr::{ - AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokensConfig, router_with_auth, + AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokensConfig, router, router_with_auth, }; use serde_json::{Value, json}; use std::{ @@ -283,3 +283,43 @@ async fn max_users_minus_one_disables_registration_end_to_end() { .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } + +#[tokio::test] +async fn missing_max_users_disables_registration_end_to_end() { + let storage = TempDir::new().unwrap(); + let config = Config::static_serve(listen(), storage.path().to_path_buf()); + let app = router(config); + + let response = app + .oneshot(put_json("/-/user/org.couchdb.user:newbie", adduser_body("newbie", "anything"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_bytes(response.into_body()).await; + assert!( + !body.windows(b"\"token\"".len()).any(|window| window == b"\"token\""), + "registration denial must not issue a token", + ); +} + +#[tokio::test] +async fn finite_max_users_reaches_in_memory_backend_end_to_end() { + let storage = TempDir::new().unwrap(); + let mut config = Config::static_serve(listen(), storage.path().to_path_buf()); + config.auth.htpasswd.max_users = MaxUsers::Limited(1); + let auth = AuthState::load(&config.auth, &config.backend).await.unwrap(); + let app = router_with_auth(config, auth); + + let first = app + .clone() + .oneshot(put_json("/-/user/org.couchdb.user:alice", adduser_body("alice", "secret"))) + .await + .unwrap(); + assert_eq!(first.status(), StatusCode::CREATED); + + let second = app + .oneshot(put_json("/-/user/org.couchdb.user:bob", adduser_body("bob", "secret"))) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::FORBIDDEN); +} diff --git a/pnpr/crates/pnpr/tests/auth_publish.rs b/pnpr/crates/pnpr/tests/auth_publish.rs index 43ba266ad3..7d62c9a5df 100644 --- a/pnpr/crates/pnpr/tests/auth_publish.rs +++ b/pnpr/crates/pnpr/tests/auth_publish.rs @@ -11,7 +11,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, router}; +use pnpr::{Config, MaxUsers, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -24,6 +24,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } @@ -35,8 +36,9 @@ fn static_config_with_packages(dir: &TempDir, packages_block: &str) -> (Config, format!("storage: {}\nuplinks: {{}}\npackages:\n{packages_block}\n", storage.display()); let config_path = dir.path().join("config.yaml"); std::fs::write(&config_path, yaml).unwrap(); - let config = + let mut config = Config::from_yaml(&config_path, listen, Some("http://example.test".to_string())).unwrap(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; (config, storage) } diff --git a/pnpr/crates/pnpr/tests/auth_user_endpoints.rs b/pnpr/crates/pnpr/tests/auth_user_endpoints.rs index 51192ee14c..ffbff81677 100644 --- a/pnpr/crates/pnpr/tests/auth_user_endpoints.rs +++ b/pnpr/crates/pnpr/tests/auth_user_endpoints.rs @@ -6,7 +6,7 @@ use axum::{ body::{Body, to_bytes}, - http::{Request, StatusCode}, + http::{HeaderValue, Request, StatusCode, header}, }; use pnpr::{ AuthConfig, AuthState, Config, HtpasswdConfig, MaxUsers, TokenStore, TokensConfig, @@ -29,6 +29,7 @@ fn listen() -> SocketAddr { fn static_config(storage: PathBuf) -> Config { let mut config = Config::static_serve(listen(), storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } @@ -175,6 +176,31 @@ async fn whoami_returns_401_for_unknown_bearer() { assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } +#[tokio::test] +async fn whoami_rejects_duplicate_authorization_headers() { + let tmp = TempDir::new().unwrap(); + let app = router(static_config(tmp.path().to_path_buf())); + let (app, token) = add_user_and_get_token(app, "alice", "secret").await; + let mut request = get_with_bearer("/-/whoami", &token); + request + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-second-value")); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn whoami_rejects_non_text_authorization_header() { + let tmp = TempDir::new().unwrap(); + let app = router(static_config(tmp.path().to_path_buf())); + let mut request = Request::get("/-/whoami").body(Body::empty()).unwrap(); + request.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_bytes(&[0xff]).unwrap()); + + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + #[tokio::test] async fn profile_returns_user_info() { let tmp = TempDir::new().unwrap(); diff --git a/pnpr/crates/pnpr/tests/batch_publish.rs b/pnpr/crates/pnpr/tests/batch_publish.rs index 8390a3feb3..d3ae719c2c 100644 --- a/pnpr/crates/pnpr/tests/batch_publish.rs +++ b/pnpr/crates/pnpr/tests/batch_publish.rs @@ -7,7 +7,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, router}; +use pnpr::{Config, MaxUsers, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -20,6 +20,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } diff --git a/pnpr/crates/pnpr/tests/journal_recovery.rs b/pnpr/crates/pnpr/tests/journal_recovery.rs index 8a5156506d..d81cc17c68 100644 --- a/pnpr/crates/pnpr/tests/journal_recovery.rs +++ b/pnpr/crates/pnpr/tests/journal_recovery.rs @@ -8,7 +8,7 @@ use axum::{ http::{Request, StatusCode}, }; use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; -use pnpr::{Config, recover_publish_journal, router}; +use pnpr::{Config, MaxUsers, recover_publish_journal, router}; use serde_json::{Value, json}; use std::{ net::{Ipv4Addr, SocketAddr, SocketAddrV4}, @@ -21,6 +21,7 @@ fn static_config(storage: PathBuf) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config } diff --git a/pnpr/crates/pnpr/tests/policy.rs b/pnpr/crates/pnpr/tests/policy.rs index 8fa9a56a66..fa7d1debb5 100644 --- a/pnpr/crates/pnpr/tests/policy.rs +++ b/pnpr/crates/pnpr/tests/policy.rs @@ -24,7 +24,10 @@ fn config_from_yaml(packages_block: &str) -> (TempDir, Config) { let dir = TempDir::new().unwrap(); let storage = dir.path().join("storage"); std::fs::create_dir_all(&storage).unwrap(); - let yaml = format!("storage: {}\nuplinks: {{}}\n{packages_block}\n", storage.display()); + let yaml = format!( + "storage: {}\nuplinks: {{}}\nauth:\n htpasswd:\n max_users: 100\n{packages_block}\n", + storage.display(), + ); let path = dir.path().join("config.yaml"); std::fs::write(&path, yaml).unwrap(); let config = diff --git a/pnpr/crates/pnpr/tests/s3_backend.rs b/pnpr/crates/pnpr/tests/s3_backend.rs index a9be91daff..a9ab24dc3f 100644 --- a/pnpr/crates/pnpr/tests/s3_backend.rs +++ b/pnpr/crates/pnpr/tests/s3_backend.rs @@ -10,7 +10,7 @@ use axum::{ use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use futures_util::StreamExt; use object_store::{ObjectStore, memory::InMemory, path::Path as ObjectPath}; -use pnpr::{Config, HostedStoreConfig, router}; +use pnpr::{Config, HostedStoreConfig, MaxUsers, router}; use serde_json::{Value, json}; use std::{ fmt::Write, @@ -27,6 +27,7 @@ fn s3_config(storage: PathBuf, store: Arc) -> Config { let listen = SocketAddr::V4(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 4873)); let mut config = Config::static_serve(listen, storage); config.public_url = "http://example.test".to_string(); + config.auth.htpasswd.max_users = MaxUsers::Unlimited; config.hosted_store = HostedStoreConfig::S3 { store, prefix: String::new() }; config } diff --git a/pnpr/crates/pnpr/tests/server.rs b/pnpr/crates/pnpr/tests/server.rs index e48e2c92ee..960008c2c5 100644 --- a/pnpr/crates/pnpr/tests/server.rs +++ b/pnpr/crates/pnpr/tests/server.rs @@ -1,16 +1,22 @@ use axum::{ - body::{Body, to_bytes}, - http::{Request, StatusCode}, + body::{Body, Bytes, to_bytes}, + http::{HeaderValue, Request, StatusCode, header}, }; use flate2::read::GzDecoder; -use pnpr::{Config, router}; +use futures_util::stream; +use pnpr::{AuthState, Config, router, router_with_auth}; use serde_json::{Value, json}; use ssri::{Algorithm, IntegrityOpts}; use std::{ + convert::Infallible, fs, io::Read as _, net::{Ipv4Addr, SocketAddr, SocketAddrV4}, path::{Path, PathBuf}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, time::Duration, }; use tempfile::TempDir; @@ -33,6 +39,243 @@ async fn body_bytes(body: Body) -> Vec { to_bytes(body, usize::MAX).await.expect("read body").to_vec() } +fn git_resolve_request(repo_url: &str, authorization: Option<&str>) -> Request { + let body = json!({ + "dependencies": { + "git-dependency": format!("git+{repo_url}#main"), + }, + "registry": "http://127.0.0.1:1/", + "trustLockfile": true, + "preferFrozenLockfile": false, + }); + let mut request = + Request::post("/-/pnpr/v0/resolve").header("content-type", "application/json"); + if let Some(authorization) = authorization { + request = request.header("authorization", authorization); + } + request.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} + +fn verify_lockfile_request(registry_url: &str, authorization: Option<&str>) -> Request { + let body = json!({ + "registry": registry_url, + "lockfile": { + "lockfileVersion": "9.0", + "settings": { + "autoInstallPeers": true, + "excludeLinksFromLockfile": false, + }, + "importers": { + ".": { + "dependencies": { + "probe-pkg": { + "specifier": "1.0.0", + "version": "1.0.0", + }, + }, + }, + }, + "packages": { + "probe-pkg@1.0.0": { + "resolution": { + "integrity": "sha512-xxzPGZ4P2uN6rROUa5N9Z7zTX6ERuE0hs6GUOc/cKBLF2NqKc16UwqHMt3tFg4CO6EBTE5UecUasg+3jZx3Ckg==", + }, + }, + }, + "snapshots": { + "probe-pkg@1.0.0": {}, + }, + }, + "minimumReleaseAge": 1, + "minimumReleaseAgeIgnoreMissingTime": false, + }); + let mut request = + Request::post("/-/pnpr/v0/verify-lockfile").header("content-type", "application/json"); + if let Some(authorization) = authorization { + request = request.header("authorization", authorization); + } + request.body(Body::from(serde_json::to_vec(&body).unwrap())).unwrap() +} + +async fn drain_resolve_response(response: axum::response::Response) -> (StatusCode, Vec) { + let status = response.status(); + let body = tokio::time::timeout(Duration::from_secs(10), body_bytes(response.into_body())) + .await + .expect("resolver response should finish within 10 seconds"); + (status, body) +} + +async fn spawn_git_probe() -> (String, Arc) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let request_count = Arc::new(AtomicUsize::new(0)); + let probe_count = Arc::clone(&request_count); + tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + probe_count.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + let mut buf = vec![0u8; 4096]; + let _ = socket.read(&mut buf).await; + let _ = socket + .write_all( + b"HTTP/1.1 500 Internal Server Error\r\n\ + Content-Length: 0\r\n\ + Connection: close\r\n\ + \r\n", + ) + .await; + }); + } + }); + (format!("http://{addr}/repo.git"), request_count) +} + +#[tokio::test] +async fn anonymous_resolve_cannot_trigger_git_egress() { + let (repo_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let response = app.oneshot(git_resolve_request(&repo_url, None)).await.unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(String::from_utf8_lossy(&body).contains("Authentication required")); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn default_registration_cannot_mint_a_resolver_credential() { + let (repo_url, request_count) = spawn_git_probe().await; + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let registration = json!({ + "_id": "org.couchdb.user:outsider", + "name": "outsider", + "password": "secret", + "email": "outsider@example.test", + "type": "user", + "roles": [], + }); + let response = app + .clone() + .oneshot( + Request::put("/-/user/org.couchdb.user:outsider") + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(®istration).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(!String::from_utf8_lossy(&body_bytes(response.into_body()).await).contains("token")); + + let response = app.oneshot(git_resolve_request(&repo_url, None)).await.unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_resolve_is_rejected_before_the_body_is_collected() { + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let body = Body::from_stream(stream::pending::>()); + let request = Request::post("/-/pnpr/v0/resolve") + .header("content-type", "application/json") + .header("content-length", "1000000") + .body(body) + .unwrap(); + + let response = tokio::time::timeout(Duration::from_millis(250), app.oneshot(request)) + .await + .expect("authentication must finish without waiting for the request body") + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn resolve_rejects_duplicate_authorization_headers() { + let (repo_url, request_count) = spawn_git_probe().await; + let tmp = TempDir::new().unwrap(); + let auth = AuthState::in_memory(); + let token = auth.tokens.issue("alice").await.unwrap(); + let app = router_with_auth(config_for("http://127.0.0.1:1", tmp.path().to_path_buf()), auth); + let mut request = git_resolve_request(&repo_url, Some(&format!("Bearer {token}"))); + request + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-second-value")); + + let response = app.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let mut reversed = git_resolve_request(&repo_url, None); + reversed + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_static("Bearer invalid-first-value")); + reversed + .headers_mut() + .append(header::AUTHORIZATION, HeaderValue::from_str(&format!("Bearer {token}")).unwrap()); + let response = app.clone().oneshot(reversed).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + let mut non_text = git_resolve_request(&repo_url, None); + non_text.headers_mut().insert(header::AUTHORIZATION, HeaderValue::from_bytes(&[0xff]).unwrap()); + let response = app.oneshot(non_text).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_verify_lockfile_cannot_trigger_registry_egress() { + let (registry_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let response = app.oneshot(verify_lockfile_request(®istry_url, None)).await.unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert!(String::from_utf8_lossy(&body).contains("Authentication required")); + assert_eq!(request_count.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn anonymous_verify_lockfile_is_rejected_before_the_body_is_collected() { + let tmp = TempDir::new().unwrap(); + let app = router(config_for("http://127.0.0.1:1", tmp.path().to_path_buf())); + let body = Body::from_stream(stream::pending::>()); + let request = Request::post("/-/pnpr/v0/verify-lockfile") + .header("content-type", "application/json") + .header("content-length", "1000000") + .body(body) + .unwrap(); + + let response = tokio::time::timeout(Duration::from_millis(250), app.oneshot(request)) + .await + .expect("authentication must finish without waiting for the request body") + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn authenticated_resolve_preserves_git_dependencies() { + let (repo_url, request_count) = spawn_git_probe().await; + + let tmp = TempDir::new().unwrap(); + let auth = AuthState::in_memory(); + let token = auth.tokens.issue("alice").await.unwrap(); + let app = router_with_auth(config_for("http://127.0.0.1:1", tmp.path().to_path_buf()), auth); + let response = app + .oneshot(git_resolve_request(&repo_url, Some(&format!("Bearer {token}")))) + .await + .unwrap(); + let (status, body) = drain_resolve_response(response).await; + + assert_eq!(status, StatusCode::OK); + assert!(String::from_utf8_lossy(&body).contains(r#""type":"error""#)); + assert!(request_count.load(Ordering::SeqCst) >= 1); +} + async fn body_json(body: Body) -> Value { serde_json::from_slice(&body_bytes(body).await).expect("body parses as JSON") } @@ -1606,8 +1849,8 @@ async fn resolver_only_serves_resolver_endpoints_and_refuses_registry_routes() { let app = router(config); // The resolver surface stays reachable. `/-/ping` and the capability - // handshake answer 200; `/-/pnpr/v0/verify-lockfile` is mounted, so an empty - // body is a 400 (bad request) rather than a 404 (route absent) — that + // handshake answer 200; `/-/pnpr/v0/verify-lockfile` is mounted and gated, + // so an anonymous request is a 401 rather than a 404 (route absent) — that // distinction is the point of the assertion. let ping = app.clone().oneshot(Request::get("/-/ping").body(Body::empty()).unwrap()).await.unwrap(); @@ -1622,11 +1865,7 @@ async fn resolver_only_serves_resolver_endpoints_and_refuses_registry_routes() { .oneshot(Request::post("/-/pnpr/v0/verify-lockfile").body(Body::empty()).unwrap()) .await .unwrap(); - assert_ne!( - verify.status(), - StatusCode::NOT_FOUND, - "the verify-lockfile route must stay mounted when the registry is disabled", - ); + assert_eq!(verify.status(), StatusCode::UNAUTHORIZED); // Every npm-registry route is gone, not merely hidden: a packument // read, a publish, and a batch publish all 404 without any upstream