mirror of
https://github.com/pnpm/pnpm.git
synced 2026-07-14 17:52:44 -04:00
feat(pacquet): port @pnpm/network.web-auth (#12569)
* feat(network-web-auth): port web-based authentication flow from pnpm Port the `@pnpm/network.web-auth` package to a new `pacquet-network-web-auth` crate, in preparation for the registry-auth commands (login / publish) that pacquet has not ported yet. The crate provides: - `poll_for_web_auth_token` — polls a registry "done" URL for an auth token, honoring `Retry-After` and an overall timeout (`ERR_PNPM_WEBAUTH_TIMEOUT`). - `with_otp_handling` — runs an operation and, on an EOTP challenge, drives either the web-auth flow (QR code + browser open + poll) or a classic OTP prompt, then retries once. Surfaces `ERR_PNPM_OTP_NON_INTERACTIVE` and `ERR_PNPM_OTP_SECOND_CHALLENGE`. - `prompt_browser_open`, `generate_qr_code`, and the `SyntheticOtpError` helpers. Rather than porting the TypeScript context-of-closures verbatim, every side effect is a self-less capability trait composed on a single `Sys` parameter (`Clock`, `Sleep`, `WebAuthFetch`, `OpenUrl`, `EnterKeyListener`, `PromptOtp`, plus the TTY probes), with the real OS behind `Host` and fn-bound unit-struct fakes in tests. User-facing messages flow through the `Reporter` seam on a new `pnpm:global` channel, matching pnpm's `globalInfo` / `globalWarn` and rendered by `pacquet-default-reporter`. New workspace dependencies: `qrcode` (no default features) and `open`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * style(network-web-auth): satisfy perfectionist dylint lints CI's dylint job flagged the new crate against the `perfectionist` lint library (which clippy does not run). Fix every finding: - Reorder derive lists to the configured `prefix_then_alphabetical` order (`Debug, Default, Clone, ...`). - Rename the single-letter `R` reporter generic to `Reporter`, bound as `self::Reporter`, matching the existing pacquet convention. - Move the inline `mod tests { ... }` blocks for `capabilities`, `generate_qr_code`, and `web_auth_timeout_error` into external `tests.rs` files (the crate's `unit_test_file_layout` is `external_only`). - Add trailing commas to multi-line macro invocations. No behavior change. Verified clean with the same command CI runs: `RUSTFLAGS='-D warnings' cargo dylint --all -- --all-targets --workspace`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * fix(network-web-auth): cancel the web-auth Enter listener on drop The production `EnterKeyListener` used `spawn_blocking` + a blocking `stdin().read_line()`, which cannot be cancelled: if the user never pressed Enter (e.g. authentication completed via a phone QR scan), the reader thread lingered until process exit. Replace it with a dedicated thread that polls stdin with a 100ms timeout via `crossterm::event::poll` and re-checks a cancel flag each tick, so dropping the handle stops the thread within one poll interval. `crossterm` reads in the terminal's default (cooked) mode — no raw mode — matching pnpm's plain `readline.createInterface({ input: process.stdin })`, which reacts to a submitted line rather than individual keypresses. In Node, stdin is event-loop-driven so `rl.close()` needs no thread to cancel; the poll loop is the Rust equivalent. Adds `crossterm` to `[workspace.dependencies]`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * fix(network-web-auth): don't let a dropped Enter listener steal stdin A code review of the cancellable web-auth listener surfaced two items: - The polling reader thread could consume one keystroke in the window between the handle being dropped (cancel flag set) and the thread noticing it: `event::poll` reports input, then `event::read` swallows a key meant for whatever reads stdin next. Re-check the cancel flag after poll and before read so a dropped handle leaves the input buffered, matching pnpm's clean `readline` close. - Drop the redundant `Option<sender>`: the Enter arm returns right after sending, so the oneshot sender moves directly into `send()`. Also document that the handle is meant to be raced and never resolves on a stdin read error, so it must not be awaited on its own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * docs(network-web-auth): de-duplicate Enter-listener comments Review follow-up: keep the polling/cancel rationale on `HostEnterHandle` (where `Drop` lives) and trim `listen`'s doc to its unique cooked-mode detail; drop a select-arm comment sentence that restated automatic `Drop` and the function's own doc comment. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * docs(network-web-auth): correct the Enter-listener cancellation rationale Round-3 review follow-up: - The `HostEnterHandle` doc blamed `std`'s blocking `read_line` for needing a poll loop, but the listener uses crossterm's `event::read()` (`read_line` was the pre-crossterm implementation). Name the real primitive. - Drop the `listen` doc's reference to the private `HostEnterHandle` type (a public method's docs must not name a more-private item); point to "the returned handle" instead. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RWejcxTU8n144a1KK4nRj8 * docs(network-web-auth): state the why in the clock test doc The doc comment on host_clock_reads_a_non_zero_time restated the test name and its assertion instead of the non-obvious reason behind it. Replace it with the rationale: 0 is Host::now_ms's pre-epoch fallback, so a non-zero read proves the real wall clock was queried. --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Zoltan Kochan <z@kochan.io>
This commit is contained in:
119
Cargo.lock
generated
119
Cargo.lock
generated
@@ -1133,6 +1133,33 @@ version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crossterm"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"crossterm_winapi",
|
||||
"derive_more",
|
||||
"document-features",
|
||||
"mio",
|
||||
"parking_lot",
|
||||
"rustix 1.1.4",
|
||||
"signal-hook",
|
||||
"signal-hook-mio",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossterm_winapi"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
|
||||
dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
@@ -1426,6 +1453,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
@@ -2582,6 +2618,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-docker"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
@@ -2593,6 +2638,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-wsl"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||
dependencies = [
|
||||
"is-docker",
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_ci"
|
||||
version = "1.2.0"
|
||||
@@ -2984,6 +3039,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -3124,6 +3185,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -3455,6 +3517,17 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2fbaa89d2ddc8473c78a3adf69eea8cffa28c483b8e02a971ef31527cd0fc92c"
|
||||
dependencies = [
|
||||
"is-wsl",
|
||||
"libc",
|
||||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
@@ -4146,6 +4219,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-network-web-auth"
|
||||
version = "0.0.1"
|
||||
dependencies = [
|
||||
"crossterm",
|
||||
"derive_more",
|
||||
"dialoguer",
|
||||
"open",
|
||||
"pacquet-diagnostics",
|
||||
"pacquet-reporter",
|
||||
"pretty_assertions",
|
||||
"qrcode",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pacquet-pack"
|
||||
version = "0.0.1"
|
||||
@@ -5220,6 +5312,12 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -6067,6 +6165,27 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"signal-hook-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-mio"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"mio",
|
||||
"signal-hook",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
version = "1.4.8"
|
||||
|
||||
@@ -42,6 +42,7 @@ pacquet-lockfile-preferred-versions = { path = "pacquet/crates/lockfile-pr
|
||||
pacquet-lockfile-verification = { path = "pacquet/crates/lockfile-verification" }
|
||||
pacquet-modules-yaml = { path = "pacquet/crates/modules-yaml" }
|
||||
pacquet-network = { path = "pacquet/crates/network" }
|
||||
pacquet-network-web-auth = { path = "pacquet/crates/network-web-auth" }
|
||||
pacquet-config = { path = "pacquet/crates/config" }
|
||||
pacquet-config-dir = { path = "pacquet/crates/config-dir" }
|
||||
pacquet-config-parse-overrides = { path = "pacquet/crates/config-parse-overrides" }
|
||||
@@ -100,6 +101,7 @@ base64 = { version = "0.22.1" }
|
||||
bcrypt = { version = "0.19.1" }
|
||||
bytes = { version = "1.11.0" }
|
||||
chrono = { version = "0.4.44", default-features = false, features = ["clock"] }
|
||||
crossterm = { version = "0.29.0" }
|
||||
dashmap = { version = "6.2.1" }
|
||||
derive_more = { version = "2.1.1", features = ["full"] }
|
||||
dialoguer = { version = "0.12.0", default-features = false }
|
||||
@@ -119,6 +121,7 @@ getrandom = { version = "0.4.2" }
|
||||
miette = { version = "7.6.0", features = ["fancy"] }
|
||||
num_cpus = { version = "1.17.0" }
|
||||
object_store = { version = "0.13", features = ["aws"] }
|
||||
open = { version = "5.3.5" }
|
||||
os_display = { version = "0.1.4" }
|
||||
owo-colors = { version = "4", features = ["supports-colors"] }
|
||||
reflink-copy = { version = "0.1.29" }
|
||||
@@ -137,6 +140,7 @@ p256 = { version = "0.13.2", default-features = false, features = ["ecdsa", "pkc
|
||||
pathdiff = { version = "0.2.3" }
|
||||
pipe-trait = { version = "0.4.0" }
|
||||
pgp = { version = "0.19.0", default-features = false }
|
||||
qrcode = { version = "0.14.1", default-features = false }
|
||||
rayon = { version = "1.12.0" }
|
||||
rmp-serde = { version = "1.3.0" }
|
||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||
|
||||
@@ -317,6 +317,10 @@ impl ReporterState {
|
||||
LogEvent::LockfileVerification(log) => self.on_lockfile_verification(&log.message),
|
||||
LogEvent::RequestRetry(log) => self.on_request_retry(log),
|
||||
LogEvent::Pnpm(log) => self.on_pnpm(log.level, &log.message, &log.prefix),
|
||||
// `pnpm:global` shares the "other" log stream with the `pnpm`
|
||||
// channel but carries no prefix, so it always renders (the
|
||||
// empty-prefix path in `on_pnpm`).
|
||||
LogEvent::Global(log) => self.on_pnpm(log.level, &log.message, ""),
|
||||
LogEvent::ExecutionTime(log) => self.on_execution_time(log),
|
||||
// Debug-only / non-rendered channels in pnpm's default reporter.
|
||||
LogEvent::Hook(_) | LogEvent::BrokenModules(_) => {}
|
||||
|
||||
@@ -8,10 +8,10 @@ use pacquet_default_reporter::{
|
||||
state::{Output, ReporterState},
|
||||
};
|
||||
use pacquet_reporter::{
|
||||
AddedRoot, ContextLog, DependencyType, ExecutionTimeLog, LifecycleLog, LifecycleMessage,
|
||||
LifecycleStdio, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog, PnpmLog,
|
||||
ProgressLog, ProgressMessage, RootLog, RootMessage, Stage, StageLog, StatsLog, StatsMessage,
|
||||
SummaryLog,
|
||||
AddedRoot, ContextLog, DependencyType, ExecutionTimeLog, GlobalLog, LifecycleLog,
|
||||
LifecycleMessage, LifecycleStdio, LogEvent, LogLevel, PackageImportMethod,
|
||||
PackageImportMethodLog, PnpmLog, ProgressLog, ProgressMessage, RootLog, RootMessage, Stage,
|
||||
StageLog, StatsLog, StatsMessage, SummaryLog,
|
||||
};
|
||||
|
||||
const CWD: &str = "/repo";
|
||||
@@ -223,6 +223,22 @@ fn already_up_to_date_pnpm_log_renders() {
|
||||
assert_eq!(frame, "Already up to date");
|
||||
}
|
||||
|
||||
/// A `pnpm:global` info message renders as a block, like the prefix-less
|
||||
/// `pnpm`-channel path — the web-auth flow surfaces the auth URL this way.
|
||||
#[test]
|
||||
fn global_info_log_renders() {
|
||||
let mut reporter = state(false);
|
||||
let frame = render(
|
||||
&mut reporter,
|
||||
vec![LogEvent::Global(GlobalLog {
|
||||
level: LogLevel::Info,
|
||||
message: "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc"
|
||||
.to_string(),
|
||||
})],
|
||||
);
|
||||
assert_eq!(frame, "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_install_frame_orders_blocks_like_pnpm() {
|
||||
let mut reporter = state(false);
|
||||
|
||||
32
pacquet/crates/network-web-auth/Cargo.toml
Normal file
32
pacquet/crates/network-web-auth/Cargo.toml
Normal file
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "pacquet-network-web-auth"
|
||||
description = "Web-based authentication flow with QR code display and token polling"
|
||||
version = "0.0.1"
|
||||
publish = false
|
||||
authors.workspace = true
|
||||
edition.workspace = true
|
||||
homepage.workspace = true
|
||||
keywords.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
crossterm = { workspace = true }
|
||||
derive_more = { workspace = true }
|
||||
dialoguer = { workspace = true }
|
||||
open = { workspace = true }
|
||||
pacquet-diagnostics = { workspace = true }
|
||||
pacquet-reporter = { workspace = true }
|
||||
qrcode = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "sync", "time"] }
|
||||
url = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
310
pacquet/crates/network-web-auth/src/capabilities.rs
Normal file
310
pacquet/crates/network-web-auth/src/capabilities.rs
Normal file
@@ -0,0 +1,310 @@
|
||||
//! Per-capability dependency-injection traits and the production [`Host`]
|
||||
//! provider for the web-authentication flow.
|
||||
//!
|
||||
//! Each trait is a single side effect the TypeScript package injected as a
|
||||
//! `context` closure (`Date.now`, `setTimeout`, `fetch`, `enquirer.input`,
|
||||
//! `createReadlineInterface`, `open`). Functions bind only the capabilities
|
||||
//! they consume, composed on one `Sys` type parameter; production callers
|
||||
//! turbofish [`Host`], and tests inject `fn`-bound unit-struct fakes. See
|
||||
//! the "Dependency injection for tests" section of
|
||||
//! `pacquet/CODE_STYLE_GUIDE.md`.
|
||||
|
||||
use std::{
|
||||
future::Future,
|
||||
io::{self, IsTerminal},
|
||||
pin::Pin,
|
||||
sync::{
|
||||
Arc, LazyLock,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
task::{Context, Poll},
|
||||
thread,
|
||||
time::{Duration, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
||||
|
||||
use crate::poll_for_web_auth_token::{WebAuthFetchOptions, WebAuthFetchResponse};
|
||||
|
||||
/// Read the current wall-clock time as Unix-epoch milliseconds.
|
||||
///
|
||||
/// Mirrors TS `Date.now()`. The poll loop measures elapsed time against a
|
||||
/// timeout and stamps [`WebAuthTimeoutError`](crate::WebAuthTimeoutError)'s
|
||||
/// `start_time` / `end_time` with these values, so the unit is
|
||||
/// milliseconds rather than a `SystemTime`.
|
||||
pub trait Clock {
|
||||
fn now_ms() -> u64;
|
||||
}
|
||||
|
||||
/// Sleep for `ms` milliseconds. Mirrors TS `setTimeout(resolve, ms)`.
|
||||
pub trait Sleep {
|
||||
fn sleep_ms(ms: u64) -> impl Future<Output = ()>;
|
||||
}
|
||||
|
||||
/// Perform the registry "done"-URL poll request. Mirrors TS
|
||||
/// `fetch(url, options)`: a single `GET` whose response exposes status,
|
||||
/// headers, and a separately fallible body.
|
||||
///
|
||||
/// The `Err` arm stands in for `fetch` itself rejecting (a network
|
||||
/// failure) — distinct from a successful response whose body fails to
|
||||
/// parse, which the caller reads from
|
||||
/// [`WebAuthFetchResponse::token`](crate::WebAuthFetchResponse::token).
|
||||
pub trait WebAuthFetch {
|
||||
fn fetch(
|
||||
url: &str,
|
||||
options: &WebAuthFetchOptions,
|
||||
) -> impl Future<Output = Result<WebAuthFetchResponse, WebAuthFetchError>>;
|
||||
}
|
||||
|
||||
/// `fetch` itself failed — the request never produced a response. The
|
||||
/// cause is intentionally opaque: the poll loop treats every fetch failure
|
||||
/// the same way (swallow and retry on the next tick), so it never inspects
|
||||
/// this value, matching the TS `catch { continue }`.
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error)]
|
||||
#[display("web-auth poll request failed")]
|
||||
#[non_exhaustive]
|
||||
pub struct WebAuthFetchError;
|
||||
|
||||
/// Whether stdin is connected to an interactive terminal. Mirrors TS
|
||||
/// `process.stdin.isTTY`.
|
||||
pub trait StdinIsTty {
|
||||
fn stdin_is_tty() -> bool;
|
||||
}
|
||||
|
||||
/// Whether stdout is connected to an interactive terminal. Mirrors TS
|
||||
/// `process.stdout.isTTY`.
|
||||
pub trait StdoutIsTty {
|
||||
fn stdout_is_tty() -> bool;
|
||||
}
|
||||
|
||||
/// Open `url` in the user's default browser. Mirrors the TS `open`
|
||||
/// package.
|
||||
pub trait OpenUrl {
|
||||
fn open_url(url: &str) -> io::Result<()>;
|
||||
}
|
||||
|
||||
/// Set up an interactive "press Enter" listener on stdin. Mirrors TS
|
||||
/// `createReadlineInterface` plus its `once('line', ...)` registration.
|
||||
///
|
||||
/// [`listen`](Self::listen) returns a [`Handle`](Self::Handle) that is a
|
||||
/// `Future` resolving once the user presses Enter; **dropping the handle
|
||||
/// closes the listener** (mirrors `readline.Interface.close`). Setup
|
||||
/// itself can fail (e.g. raw mode unsupported), which the caller treats as
|
||||
/// "warn and fall back to plain polling".
|
||||
pub trait EnterKeyListener {
|
||||
type Handle: Future<Output = ()> + Unpin;
|
||||
fn listen() -> io::Result<Self::Handle>;
|
||||
}
|
||||
|
||||
/// Prompt the user for a classic one-time password. Mirrors TS
|
||||
/// `enquirer.input({ message })`: returns the entered string, `None` when
|
||||
/// the prompt yields no value, or an error.
|
||||
pub trait PromptOtp {
|
||||
fn input(message: &str) -> impl Future<Output = Result<Option<String>, PromptError>>;
|
||||
}
|
||||
|
||||
/// Failure surface of [`PromptOtp::input`].
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum PromptError {
|
||||
/// The user aborted the prompt (Ctrl-C). Mirrors enquirer's
|
||||
/// `ExitPromptError`, which the caller handles by re-throwing the
|
||||
/// original OTP challenge rather than this error.
|
||||
#[display("the one-time password prompt was cancelled")]
|
||||
Cancelled,
|
||||
/// Any other failure while reading the prompt.
|
||||
#[display("failed to read the one-time password prompt: {reason}")]
|
||||
Other { reason: String },
|
||||
}
|
||||
|
||||
/// Production implementation of every capability trait in this crate. Each
|
||||
/// method calls into the real OS facility (`SystemTime`, `tokio`,
|
||||
/// `reqwest`, `dialoguer`, the `open` crate, stdin).
|
||||
pub struct Host;
|
||||
|
||||
impl Clock for Host {
|
||||
fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_or(0, |elapsed| u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX))
|
||||
}
|
||||
}
|
||||
|
||||
impl Sleep for Host {
|
||||
async fn sleep_ms(ms: u64) {
|
||||
tokio::time::sleep(Duration::from_millis(ms)).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl WebAuthFetch for Host {
|
||||
async fn fetch(
|
||||
url: &str,
|
||||
options: &WebAuthFetchOptions,
|
||||
) -> Result<WebAuthFetchResponse, WebAuthFetchError> {
|
||||
// One process-wide client so the poll loop reuses connections
|
||||
// instead of opening a fresh pool every second.
|
||||
static CLIENT: LazyLock<reqwest::Client> = LazyLock::new(reqwest::Client::new);
|
||||
|
||||
let mut request = CLIENT.get(url);
|
||||
if let Some(timeout) = options.timeout {
|
||||
request = request.timeout(Duration::from_millis(timeout));
|
||||
}
|
||||
let response = request.send().await.map_err(|_| WebAuthFetchError)?;
|
||||
let ok = response.status().is_success();
|
||||
let status = response.status().as_u16();
|
||||
let retry_after = response
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
// A body read failure is treated the same as `response.json()`
|
||||
// rejecting upstream: an empty body makes `token()` fail to parse,
|
||||
// so the poll loop retries — while `status` / `retry_after` stay
|
||||
// usable for the 202 branch.
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
Ok(WebAuthFetchResponse { ok, status, retry_after, body })
|
||||
}
|
||||
}
|
||||
|
||||
impl StdinIsTty for Host {
|
||||
fn stdin_is_tty() -> bool {
|
||||
io::stdin().is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
impl StdoutIsTty for Host {
|
||||
fn stdout_is_tty() -> bool {
|
||||
io::stdout().is_terminal()
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenUrl for Host {
|
||||
fn open_url(url: &str) -> io::Result<()> {
|
||||
// Detached so the call returns as soon as the browser is launched
|
||||
// rather than blocking the async task until the launcher exits.
|
||||
open::that_detached(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// How often the listener thread wakes to re-check the cancel flag. Bounds
|
||||
/// how long the detached thread outlives a dropped handle.
|
||||
const ENTER_POLL_INTERVAL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Production [`EnterKeyListener::Handle`]. Resolves once the background
|
||||
/// thread observes an Enter keypress; dropping it sets the cancel flag so
|
||||
/// that thread stops within one [`ENTER_POLL_INTERVAL`] — crossterm's
|
||||
/// blocking `event::read()` cannot be cancelled, so the thread polls with
|
||||
/// a timeout instead of blocking forever.
|
||||
///
|
||||
/// Meant to be raced and dropped when another branch wins (e.g. inside a
|
||||
/// `tokio::select!`): on a stdin read error it deliberately never resolves,
|
||||
/// so awaiting it on its own would hang.
|
||||
pub struct HostEnterHandle {
|
||||
enter: tokio::sync::oneshot::Receiver<()>,
|
||||
fired: bool,
|
||||
cancel: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Future for HostEnterHandle {
|
||||
type Output = ();
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
let this = self.get_mut();
|
||||
if this.fired {
|
||||
return Poll::Pending;
|
||||
}
|
||||
match Pin::new(&mut this.enter).poll(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
// Enter pressed: the reader thread signalled.
|
||||
Poll::Ready(Ok(())) => {
|
||||
this.fired = true;
|
||||
Poll::Ready(())
|
||||
}
|
||||
// Reader exited without signalling (read error): never fire, so
|
||||
// the browser is not opened spuriously.
|
||||
Poll::Ready(Err(_)) => {
|
||||
this.fired = true;
|
||||
Poll::Pending
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for HostEnterHandle {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
impl EnterKeyListener for Host {
|
||||
type Handle = HostEnterHandle;
|
||||
|
||||
/// Watch stdin for an Enter keypress without blocking uninterruptibly;
|
||||
/// the returned handle documents the cancellation contract. `crossterm`
|
||||
/// reads in the terminal's default (cooked) mode — no raw mode — matching
|
||||
/// pnpm's plain `readline.createInterface({ input: process.stdin })`,
|
||||
/// which reacts to a submitted line rather than individual keys.
|
||||
fn listen() -> io::Result<HostEnterHandle> {
|
||||
let (tx, enter) = tokio::sync::oneshot::channel();
|
||||
let cancel = Arc::new(AtomicBool::new(false));
|
||||
let reader_cancel = Arc::clone(&cancel);
|
||||
thread::Builder::new().name("web-auth-enter-listener".to_owned()).spawn(move || {
|
||||
while !reader_cancel.load(Ordering::Relaxed) {
|
||||
match event::poll(ENTER_POLL_INTERVAL) {
|
||||
// Input is ready, but skip it without consuming when the
|
||||
// handle was dropped meanwhile — otherwise `read()` would
|
||||
// steal a keystroke from whatever reads stdin next.
|
||||
Ok(true) => {
|
||||
if reader_cancel.load(Ordering::Relaxed) {
|
||||
return;
|
||||
}
|
||||
// `poll() == Ok(true)` guarantees a complete event is
|
||||
// ready, so `read()` does not block. In cooked mode the
|
||||
// line is submitted on Enter, which maps to `Enter`.
|
||||
match event::read() {
|
||||
Ok(Event::Key(key))
|
||||
if key.code == KeyCode::Enter
|
||||
&& key.kind != KeyEventKind::Release =>
|
||||
{
|
||||
let _ = tx.send(());
|
||||
return;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
// Timed out: loop back to re-check the cancel flag.
|
||||
Ok(false) => {}
|
||||
Err(_) => return,
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(HostEnterHandle { enter, fired: false, cancel })
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptOtp for Host {
|
||||
async fn input(message: &str) -> Result<Option<String>, PromptError> {
|
||||
let message = message.to_owned();
|
||||
// `dialoguer` is blocking; keep it off the async runtime.
|
||||
tokio::task::spawn_blocking(move || {
|
||||
dialoguer::Input::<String>::new().with_prompt(message).allow_empty(true).interact_text()
|
||||
})
|
||||
.await
|
||||
.map_err(|join_error| PromptError::Other { reason: join_error.to_string() })?
|
||||
.map(Some)
|
||||
.map_err(map_dialoguer_error)
|
||||
}
|
||||
}
|
||||
|
||||
fn map_dialoguer_error(error: dialoguer::Error) -> PromptError {
|
||||
match error {
|
||||
dialoguer::Error::IO(io) if io.kind() == io::ErrorKind::Interrupted => {
|
||||
PromptError::Cancelled
|
||||
}
|
||||
dialoguer::Error::IO(io) => PromptError::Other { reason: io.to_string() },
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
20
pacquet/crates/network-web-auth/src/capabilities/tests.rs
Normal file
20
pacquet/crates/network-web-auth/src/capabilities/tests.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use super::{Clock, Host, StdinIsTty, StdoutIsTty};
|
||||
|
||||
/// `0` is `Host::now_ms`'s pre-epoch fallback, so a non-zero read confirms
|
||||
/// the real wall clock was queried rather than the fallback.
|
||||
#[test]
|
||||
fn host_clock_reads_a_non_zero_time() {
|
||||
let now = Host::now_ms();
|
||||
eprintln!("Host::now_ms() = {now}");
|
||||
assert!(now > 0);
|
||||
}
|
||||
|
||||
/// The TTY probes are dispatchable and return a bool. The value depends on
|
||||
/// how the test harness wired stdio, so only its type is asserted — the
|
||||
/// behavioral branches are covered by fakes in the `prompt_browser_open` /
|
||||
/// `with_otp_handling` tests.
|
||||
#[test]
|
||||
fn host_tty_probes_are_callable() {
|
||||
let _: bool = Host::stdin_is_tty();
|
||||
let _: bool = Host::stdout_is_tty();
|
||||
}
|
||||
29
pacquet/crates/network-web-auth/src/generate_qr_code.rs
Normal file
29
pacquet/crates/network-web-auth/src/generate_qr_code.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use qrcode::{QrCode, render::unicode};
|
||||
|
||||
/// Render `text` as a compact Unicode QR code for printing in a terminal.
|
||||
///
|
||||
/// Ports pnpm's `generateQrCode`, which used `qrcode-terminal` with
|
||||
/// `small: true`. The exact glyphs differ (this uses `qrcode`'s
|
||||
/// half-block `Dense1x2` renderer), but the encoded payload and
|
||||
/// scannability match.
|
||||
///
|
||||
/// Returns an error only when `text` is too long to fit in any QR version
|
||||
/// — pnpm treats this as a should-never-happen, but `text` comes from an
|
||||
/// untrusted registry response, so this surfaces it as a recoverable error
|
||||
/// rather than a panic.
|
||||
pub fn generate_qr_code(text: &str) -> Result<String, GenerateQrCodeError> {
|
||||
QrCode::new(text)
|
||||
.map(|code| code.render::<unicode::Dense1x2>().build())
|
||||
.map_err(|source| GenerateQrCodeError { reason: source.to_string() })
|
||||
}
|
||||
|
||||
/// `text` could not be encoded as a QR code (e.g. it exceeds the maximum
|
||||
/// QR data capacity).
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error)]
|
||||
#[display("failed to generate a QR code: {reason}")]
|
||||
pub struct GenerateQrCodeError {
|
||||
reason: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,14 @@
|
||||
use super::generate_qr_code;
|
||||
|
||||
#[test]
|
||||
fn returns_a_non_empty_string() {
|
||||
let qr = generate_qr_code("https://example.com").expect("encode a short URL");
|
||||
assert!(!qr.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn produces_different_output_for_different_inputs() {
|
||||
let qr1 = generate_qr_code("https://example.com/a").expect("encode URL a");
|
||||
let qr2 = generate_qr_code("https://example.com/b").expect("encode URL b");
|
||||
assert_ne!(qr1, qr2);
|
||||
}
|
||||
24
pacquet/crates/network-web-auth/src/global_log.rs
Normal file
24
pacquet/crates/network-web-auth/src/global_log.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
//! The web-auth flow's equivalent of pnpm's `globalInfo` / `globalWarn`.
|
||||
//!
|
||||
//! pnpm writes these messages to a `bole('pnpm:global')` logger; pacquet
|
||||
//! routes them through the [`Reporter`] seam on the matching
|
||||
//! [`LogEvent::Global`] channel, so the `--reporter` choice (silent vs
|
||||
//! ndjson) still applies. Threaded as a `Reporter` generic rather than a
|
||||
//! capability on `Sys` because the sink is a runtime choice, not a system
|
||||
//! facility.
|
||||
|
||||
use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter};
|
||||
|
||||
pub(crate) fn global_info<Reporter: self::Reporter>(message: &str) {
|
||||
Reporter::emit(&LogEvent::Global(GlobalLog {
|
||||
level: LogLevel::Info,
|
||||
message: message.to_owned(),
|
||||
}));
|
||||
}
|
||||
|
||||
pub(crate) fn global_warn<Reporter: self::Reporter>(message: &str) {
|
||||
Reporter::emit(&LogEvent::Global(GlobalLog {
|
||||
level: LogLevel::Warn,
|
||||
message: message.to_owned(),
|
||||
}));
|
||||
}
|
||||
46
pacquet/crates/network-web-auth/src/lib.rs
Normal file
46
pacquet/crates/network-web-auth/src/lib.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Web-based authentication flow: QR-code display, token polling, and OTP
|
||||
//! challenge handling.
|
||||
//!
|
||||
//! This is the Rust port of the TypeScript `@pnpm/network.web-auth`
|
||||
//! package. It is shared infrastructure for the registry-auth commands
|
||||
//! (`pnpm login` / `pnpm publish` and friends) that pacquet has not ported
|
||||
//! yet; the crate exists so those features have the flow ready when they
|
||||
//! land.
|
||||
//!
|
||||
//! # Dependency-injection seam
|
||||
//!
|
||||
//! The TypeScript package injects every side effect — the clock, the
|
||||
//! sleep timer, `fetch`, the OTP prompt, the "press Enter" readline, the
|
||||
//! browser opener — as a bag of closures on a `context` object. This crate
|
||||
//! ports that seam to pacquet's convention: one `self`-less capability
|
||||
//! trait per effect ([`Clock`], [`Sleep`], [`WebAuthFetch`], [`OpenUrl`],
|
||||
//! [`EnterKeyListener`], [`PromptOtp`], and the [`StdinIsTty`] /
|
||||
//! [`StdoutIsTty`] probes), composed as bounds on a single `Sys` type
|
||||
//! parameter, with the real OS behind [`Host`] and `fn`-bound unit-struct
|
||||
//! fakes in tests. User-facing messages flow through the `R: Reporter` seam
|
||||
//! on pacquet's `pnpm:global` channel rather than a capability, matching
|
||||
//! pnpm's `globalInfo` / `globalWarn`.
|
||||
|
||||
mod capabilities;
|
||||
mod generate_qr_code;
|
||||
mod global_log;
|
||||
mod poll_for_web_auth_token;
|
||||
mod prompt_browser_open;
|
||||
mod web_auth_timeout_error;
|
||||
mod with_otp_handling;
|
||||
|
||||
pub use capabilities::{
|
||||
Clock, EnterKeyListener, Host, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty,
|
||||
WebAuthFetch, WebAuthFetchError,
|
||||
};
|
||||
pub use generate_qr_code::{GenerateQrCodeError, generate_qr_code};
|
||||
pub use poll_for_web_auth_token::{
|
||||
WebAuthFetchOptions, WebAuthFetchResponse, WebAuthRetryOptions, WebAuthTokenPollParams,
|
||||
poll_for_web_auth_token,
|
||||
};
|
||||
pub use prompt_browser_open::prompt_browser_open;
|
||||
pub use web_auth_timeout_error::WebAuthTimeoutError;
|
||||
pub use with_otp_handling::{
|
||||
OtpChallenge, OtpError, OtpErrorBody, OtpNonInteractiveError, OtpSecondChallengeError,
|
||||
SyntheticOtpError, WithOtpError, with_otp_handling,
|
||||
};
|
||||
161
pacquet/crates/network-web-auth/src/poll_for_web_auth_token.rs
Normal file
161
pacquet/crates/network-web-auth/src/poll_for_web_auth_token.rs
Normal file
@@ -0,0 +1,161 @@
|
||||
use crate::{
|
||||
WebAuthTimeoutError,
|
||||
capabilities::{Clock, Sleep, WebAuthFetch},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Options forwarded to each poll request. Ports TS `WebAuthFetchOptions`
|
||||
/// (the `method` is always `GET`, so it is implicit here). `retry` mirrors
|
||||
/// the undici retry knobs pnpm passes through; the production
|
||||
/// [`Host`](crate::Host) fetch currently applies only `timeout` and leaves
|
||||
/// `retry` for the consuming command to wire.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct WebAuthFetchOptions {
|
||||
/// Per-request timeout in milliseconds.
|
||||
pub timeout: Option<u64>,
|
||||
pub retry: Option<WebAuthRetryOptions>,
|
||||
}
|
||||
|
||||
/// Retry knobs forwarded with [`WebAuthFetchOptions`]. Field-for-field
|
||||
/// port of pnpm's `WebAuthFetchOptions['retry']`.
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct WebAuthRetryOptions {
|
||||
pub factor: Option<f64>,
|
||||
pub max_timeout: Option<u64>,
|
||||
pub min_timeout: Option<u64>,
|
||||
pub randomize: Option<bool>,
|
||||
pub retries: Option<u32>,
|
||||
}
|
||||
|
||||
/// A poll response materialized by the [`WebAuthFetch`] capability. Ports
|
||||
/// TS `WebAuthFetchResponse`: `ok` / `status`, the one header the poll
|
||||
/// reads (`Retry-After`), and the body text that [`token`](Self::token)
|
||||
/// parses.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebAuthFetchResponse {
|
||||
pub ok: bool,
|
||||
pub status: u16,
|
||||
/// Value of the `Retry-After` response header, if present.
|
||||
pub retry_after: Option<String>,
|
||||
/// Raw response body.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl WebAuthFetchResponse {
|
||||
/// Extract the `token` field from the JSON body. `Ok(None)` when the
|
||||
/// body parses but carries no token; `Err` when the body is not the
|
||||
/// expected JSON shape — mirroring TS `response.json()` rejecting,
|
||||
/// which the poll loop swallows.
|
||||
pub fn token(&self) -> Result<Option<String>, serde_json::Error> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenBody {
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
}
|
||||
serde_json::from_str::<TokenBody>(&self.body).map(|body| body.token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parameters for [`poll_for_web_auth_token`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WebAuthTokenPollParams {
|
||||
pub done_url: String,
|
||||
pub fetch_options: WebAuthFetchOptions,
|
||||
/// Overall budget in milliseconds. Defaults to 5 minutes when `None`.
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: u64 = 5 * 60 * 1000;
|
||||
const POLL_INTERVAL_MS: u64 = 1000;
|
||||
|
||||
/// Poll a registry's "done" URL until it returns an authentication token.
|
||||
///
|
||||
/// The caller is responsible for displaying the authentication URL (and
|
||||
/// optional QR code) before calling this. Returns the token string, or
|
||||
/// [`WebAuthTimeoutError`] when the budget is exceeded.
|
||||
///
|
||||
/// Ports pnpm's `pollForWebAuthToken`.
|
||||
pub async fn poll_for_web_auth_token<Sys>(
|
||||
params: WebAuthTokenPollParams,
|
||||
) -> Result<String, WebAuthTimeoutError>
|
||||
where
|
||||
Sys: Clock + Sleep + WebAuthFetch,
|
||||
{
|
||||
let WebAuthTokenPollParams { done_url, fetch_options, timeout_ms } = params;
|
||||
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
|
||||
let start_time = Sys::now_ms();
|
||||
|
||||
loop {
|
||||
let now = Sys::now_ms();
|
||||
if now.saturating_sub(start_time) > timeout_ms {
|
||||
return Err(WebAuthTimeoutError::new(now, start_time, timeout_ms));
|
||||
}
|
||||
|
||||
Sys::sleep_ms(POLL_INTERVAL_MS).await;
|
||||
|
||||
let Ok(response) = Sys::fetch(&done_url, &fetch_options).await else {
|
||||
continue;
|
||||
};
|
||||
if !response.ok {
|
||||
continue;
|
||||
}
|
||||
|
||||
if response.status == 202 {
|
||||
// Registry is still waiting for authentication.
|
||||
wait_for_retry_after::<Sys>(&response, start_time, timeout_ms).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
match response.token() {
|
||||
Ok(Some(token)) if !token.is_empty() => return Ok(token),
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Honor a 202 response's `Retry-After` header by sleeping the
|
||||
/// *additional* time beyond the poll interval already waited, capped to
|
||||
/// the remaining budget. Returns `Err` when the budget is already
|
||||
/// exhausted.
|
||||
async fn wait_for_retry_after<Sys>(
|
||||
response: &WebAuthFetchResponse,
|
||||
start_time: u64,
|
||||
timeout_ms: u64,
|
||||
) -> Result<(), WebAuthTimeoutError>
|
||||
where
|
||||
Sys: Clock + Sleep,
|
||||
{
|
||||
let retry_after_seconds = parse_js_number(response.retry_after.as_deref());
|
||||
if !retry_after_seconds.is_finite() {
|
||||
return Ok(());
|
||||
}
|
||||
let additional_ms = retry_after_seconds.mul_add(1000.0, -(POLL_INTERVAL_MS as f64));
|
||||
if additional_ms <= 0.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let now_after_poll = Sys::now_ms();
|
||||
let remaining_ms = timeout_ms as i64
|
||||
- i64::try_from(now_after_poll.saturating_sub(start_time)).unwrap_or(i64::MAX);
|
||||
if remaining_ms <= 0 {
|
||||
return Err(WebAuthTimeoutError::new(now_after_poll, start_time, timeout_ms));
|
||||
}
|
||||
let sleep_ms = additional_ms.min(remaining_ms as f64);
|
||||
Sys::sleep_ms(sleep_ms as u64).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mirror JavaScript's `Number(value)` for the `Retry-After` header.
|
||||
/// Header-absent (`None`) and an empty / whitespace string both map to `0`
|
||||
/// (as `Number(null)` / `Number('')` do); a non-numeric string maps to
|
||||
/// `NaN`, so the caller skips the additional wait.
|
||||
fn parse_js_number(value: Option<&str>) -> f64 {
|
||||
match value {
|
||||
None => 0.0,
|
||||
Some(raw) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() { 0.0 } else { trimmed.parse::<f64>().unwrap_or(f64::NAN) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
future::{self, Future},
|
||||
rc::Rc,
|
||||
};
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::{
|
||||
WebAuthFetchOptions, WebAuthFetchResponse, WebAuthRetryOptions, WebAuthTokenPollParams,
|
||||
poll_for_web_auth_token,
|
||||
};
|
||||
use crate::capabilities::{Clock, Sleep, WebAuthFetch, WebAuthFetchError};
|
||||
|
||||
/// A scripted stand-in for one `fetch` call, given the request URL and
|
||||
/// options so a test can both decide the response and capture the inputs.
|
||||
type FetchScript =
|
||||
Box<dyn FnMut(&str, &WebAuthFetchOptions) -> Result<WebAuthFetchResponse, WebAuthFetchError>>;
|
||||
|
||||
/// How the [`Sleep`] fake moves the [`Clock`] fake forward. The two share
|
||||
/// the `TIME` cell so a test can drive the timeout deterministically —
|
||||
/// mirroring the TS tests where `setTimeout` mutates the same `time`
|
||||
/// variable `Date.now` reads.
|
||||
#[derive(Clone, Copy)]
|
||||
enum SleepBehavior {
|
||||
/// `Date.now` is pinned; sleeping records the delay but does not move
|
||||
/// the clock.
|
||||
NoAdvance,
|
||||
/// Sleeping advances the clock by the requested milliseconds.
|
||||
AdvanceByMs,
|
||||
/// Sleeping advances the clock by a fixed amount regardless of the
|
||||
/// requested delay (the TS `setTimeout: () => { time += K }` shape).
|
||||
AdvanceByFixed(u64),
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static TIME: Cell<u64> = const { Cell::new(0) };
|
||||
static SLEEP_BEHAVIOR: Cell<SleepBehavior> = const { Cell::new(SleepBehavior::NoAdvance) };
|
||||
static SLEEPS: RefCell<Vec<u64>> = const { RefCell::new(Vec::new()) };
|
||||
static FETCH: RefCell<Option<FetchScript>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Fake;
|
||||
|
||||
impl Clock for Fake {
|
||||
fn now_ms() -> u64 {
|
||||
TIME.with(Cell::get)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sleep for Fake {
|
||||
fn sleep_ms(ms: u64) -> impl Future<Output = ()> {
|
||||
SLEEPS.with(|sleeps| sleeps.borrow_mut().push(ms));
|
||||
let delta = match SLEEP_BEHAVIOR.with(Cell::get) {
|
||||
SleepBehavior::NoAdvance => 0,
|
||||
SleepBehavior::AdvanceByMs => ms,
|
||||
SleepBehavior::AdvanceByFixed(jump) => jump,
|
||||
};
|
||||
if delta != 0 {
|
||||
TIME.with(|time| time.set(time.get().saturating_add(delta)));
|
||||
}
|
||||
future::ready(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WebAuthFetch for Fake {
|
||||
fn fetch(
|
||||
url: &str,
|
||||
options: &WebAuthFetchOptions,
|
||||
) -> impl Future<Output = Result<WebAuthFetchResponse, WebAuthFetchError>> {
|
||||
let result = FETCH.with(|fetch| {
|
||||
(fetch.borrow_mut().as_mut().expect("a fetch script must be installed"))(url, options)
|
||||
});
|
||||
future::ready(result)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the shared fake state. Called first in every test so a reused
|
||||
/// libtest worker thread never leaks state between tests.
|
||||
fn reset() {
|
||||
TIME.with(|time| time.set(0));
|
||||
SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::NoAdvance));
|
||||
SLEEPS.with(|sleeps| sleeps.borrow_mut().clear());
|
||||
FETCH.with(|fetch| *fetch.borrow_mut() = None);
|
||||
}
|
||||
|
||||
fn set_sleep_behavior(behavior: SleepBehavior) {
|
||||
SLEEP_BEHAVIOR.with(|cell| cell.set(behavior));
|
||||
}
|
||||
|
||||
fn set_fetch(script: FetchScript) {
|
||||
FETCH.with(|fetch| *fetch.borrow_mut() = Some(script));
|
||||
}
|
||||
|
||||
fn recorded_sleeps() -> Vec<u64> {
|
||||
SLEEPS.with(|sleeps| sleeps.borrow().clone())
|
||||
}
|
||||
|
||||
fn ok_202(retry_after: Option<&str>) -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse {
|
||||
ok: true,
|
||||
status: 202,
|
||||
retry_after: retry_after.map(str::to_owned),
|
||||
body: "{}".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_token(token: &str) -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
retry_after: None,
|
||||
body: serde_json::json!({ "token": token }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn ok_json(body: &serde_json::Value) -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse { ok: true, status: 200, retry_after: None, body: body.to_string() }
|
||||
}
|
||||
|
||||
fn not_ok() -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse { ok: false, status: 404, retry_after: None, body: String::new() }
|
||||
}
|
||||
|
||||
fn params(timeout_ms: Option<u64>) -> WebAuthTokenPollParams {
|
||||
WebAuthTokenPollParams {
|
||||
done_url: "https://registry.npmjs.org/auth/done".to_owned(),
|
||||
fetch_options: WebAuthFetchOptions::default(),
|
||||
timeout_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_token_when_done_url_responds_with_200_and_token() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() < 3 { ok_202(Some("1")) } else { ok_token("web-token-123") })
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "web-token-123");
|
||||
assert_eq!(calls.get(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn passes_done_url_and_fetch_options_to_fetch() {
|
||||
reset();
|
||||
let captured = Rc::new(RefCell::new(Vec::<(String, WebAuthFetchOptions)>::new()));
|
||||
let sink = Rc::clone(&captured);
|
||||
set_fetch(Box::new(move |url, options| {
|
||||
sink.borrow_mut().push((url.to_owned(), options.clone()));
|
||||
Ok(ok_token("tok"))
|
||||
}));
|
||||
let options = WebAuthFetchOptions {
|
||||
timeout: Some(5000),
|
||||
retry: Some(WebAuthRetryOptions { retries: Some(3), ..WebAuthRetryOptions::default() }),
|
||||
};
|
||||
|
||||
poll_for_web_auth_token::<Fake>(WebAuthTokenPollParams {
|
||||
done_url: "https://registry.example.com/done".to_owned(),
|
||||
fetch_options: options.clone(),
|
||||
timeout_ms: None,
|
||||
})
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert_eq!(*captured.borrow(), vec![("https://registry.example.com/done".to_owned(), options)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn respects_retry_after_header_when_polling() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { ok_202(Some("5")) } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
// First the 1s poll interval, then the 5s Retry-After minus the 1s
|
||||
// already waited, then the next iteration's 1s poll interval.
|
||||
assert_eq!(recorded_sleeps(), vec![1000, 4000, 1000]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_retry_after_when_value_is_not_a_finite_number() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { ok_202(Some("not-a-number")) } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(recorded_sleeps(), vec![1000, 1000]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ignores_retry_after_when_value_is_absent() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { ok_202(None) } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(recorded_sleeps(), vec![1000, 1000]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_additional_delay_when_retry_after_is_less_than_poll_interval() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { ok_202(Some("0.5")) } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(recorded_sleeps(), vec![1000, 1000]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn caps_retry_after_additional_delay_to_remaining_timeout() {
|
||||
reset();
|
||||
set_sleep_behavior(SleepBehavior::AdvanceByMs);
|
||||
set_fetch(Box::new(|_url, _options| Ok(ok_202(Some("60")))));
|
||||
|
||||
// A 10s budget so the 60s Retry-After gets capped.
|
||||
poll_for_web_auth_token::<Fake>(params(Some(10_000)))
|
||||
.await
|
||||
.expect_err("polling should time out");
|
||||
|
||||
let sleeps = recorded_sleeps();
|
||||
assert_eq!(sleeps[0], 1000);
|
||||
assert!(sleeps[1] <= 9000, "additional delay capped to the remaining budget, got {sleeps:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_timeout_error_when_timeout_expires_during_retry_after_wait() {
|
||||
reset();
|
||||
set_sleep_behavior(SleepBehavior::AdvanceByMs);
|
||||
set_fetch(Box::new(|_url, _options| Ok(ok_202(Some("100")))));
|
||||
|
||||
let error = poll_for_web_auth_token::<Fake>(params(Some(5000)))
|
||||
.await
|
||||
.expect_err("polling should time out");
|
||||
|
||||
assert_eq!(error.timeout, 5000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continues_polling_when_fetch_fails() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() == 1 { Err(WebAuthFetchError) } else { Ok(ok_token("tok")) }
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "tok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continues_polling_when_response_is_not_ok() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { not_ok() } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "tok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continues_polling_when_response_body_is_not_json() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 {
|
||||
WebAuthFetchResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
retry_after: None,
|
||||
body: "not json".to_owned(),
|
||||
}
|
||||
} else {
|
||||
ok_token("tok")
|
||||
})
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "tok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continues_polling_when_response_body_has_no_token() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 {
|
||||
ok_json(&serde_json::json!({ "something": "else" }))
|
||||
} else {
|
||||
ok_token("tok")
|
||||
})
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "tok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn continues_polling_when_token_is_empty_string() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() == 1 { ok_token("") } else { ok_token("real-tok") })
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "real-tok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_timeout_error_after_timeout() {
|
||||
reset();
|
||||
// Jump past the default 5-minute budget on the first sleep.
|
||||
set_sleep_behavior(SleepBehavior::AdvanceByFixed(6 * 60 * 1000));
|
||||
set_fetch(Box::new(|_url, _options| Ok(ok_202(None))));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect_err("polling should time out");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uses_custom_timeout_value() {
|
||||
reset();
|
||||
set_sleep_behavior(SleepBehavior::AdvanceByFixed(2000));
|
||||
set_fetch(Box::new(|_url, _options| Ok(ok_202(None))));
|
||||
|
||||
let error = poll_for_web_auth_token::<Fake>(params(Some(3000)))
|
||||
.await
|
||||
.expect_err("polling should time out");
|
||||
|
||||
assert_eq!(error.timeout, 3000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recovers_after_multiple_consecutive_fetch_errors() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() <= 5 { Err(WebAuthFetchError) } else { Ok(ok_token("recovered")) }
|
||||
}));
|
||||
|
||||
let token = poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(token, "recovered");
|
||||
assert_eq!(calls.get(), 6);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn waits_poll_interval_before_each_fetch_call() {
|
||||
reset();
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
Ok(if counter.get() < 4 { ok_202(None) } else { ok_token("tok") })
|
||||
}));
|
||||
|
||||
poll_for_web_auth_token::<Fake>(params(None)).await.expect("a token");
|
||||
|
||||
assert_eq!(recorded_sleeps(), vec![1000, 1000, 1000, 1000]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_timeout_error_when_remaining_time_is_zero_during_retry_after() {
|
||||
reset();
|
||||
set_sleep_behavior(SleepBehavior::AdvanceByMs);
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
set_fetch(Box::new(move |_url, _options| {
|
||||
counter.set(counter.get() + 1);
|
||||
// The first 202 carries a Retry-After that, after capping to the
|
||||
// remaining budget, lands the clock exactly on the timeout.
|
||||
Ok(if counter.get() == 1 { ok_202(Some("10")) } else { ok_202(None) })
|
||||
}));
|
||||
|
||||
let error = poll_for_web_auth_token::<Fake>(params(Some(2000)))
|
||||
.await
|
||||
.expect_err("polling should time out");
|
||||
|
||||
assert_eq!(error.timeout, 2000);
|
||||
}
|
||||
85
pacquet/crates/network-web-auth/src/prompt_browser_open.rs
Normal file
85
pacquet/crates/network-web-auth/src/prompt_browser_open.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::future::Future;
|
||||
|
||||
use pacquet_reporter::Reporter;
|
||||
|
||||
use crate::{
|
||||
capabilities::{EnterKeyListener, OpenUrl, StdinIsTty},
|
||||
global_log::{global_info, global_warn},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// Race a token-polling future against an optional "press Enter to open in
|
||||
/// browser" prompt.
|
||||
///
|
||||
/// While `poll` runs, an Enter keypress opens `auth_url` in the user's
|
||||
/// browser. The poll is awaited on its own — the keypress is a
|
||||
/// fire-and-forget side effect — so authentication that completes on
|
||||
/// another device (phone QR scan, pasted URL) returns without the user
|
||||
/// ever pressing Enter. Whatever `poll` resolves to (token or error) is
|
||||
/// returned, and the listener is always closed.
|
||||
///
|
||||
/// Falls back to awaiting `poll` directly — no prompt — when stdin is not a
|
||||
/// TTY, when `auth_url` is not an `http(s)` URL (it comes from an untrusted
|
||||
/// registry response), or when the listener fails to set up.
|
||||
///
|
||||
/// Ports pnpm's `promptBrowserOpen`.
|
||||
pub async fn prompt_browser_open<Sys, Reporter, Error, Poll>(
|
||||
auth_url: &str,
|
||||
poll: Poll,
|
||||
) -> Result<String, Error>
|
||||
where
|
||||
Sys: StdinIsTty + EnterKeyListener + OpenUrl,
|
||||
Reporter: self::Reporter,
|
||||
Poll: Future<Output = Result<String, Error>>,
|
||||
{
|
||||
if !Sys::stdin_is_tty() {
|
||||
return poll.await;
|
||||
}
|
||||
|
||||
let Some(canonical_url) = canonical_http_url(auth_url) else {
|
||||
return poll.await;
|
||||
};
|
||||
|
||||
let mut listener = match Sys::listen() {
|
||||
Ok(listener) => listener,
|
||||
Err(error) => {
|
||||
global_warn::<Reporter>(&format!("Could not set up keyboard listener: {error}"));
|
||||
return poll.await;
|
||||
}
|
||||
};
|
||||
|
||||
global_info::<Reporter>("Press ENTER to open the URL in your browser.");
|
||||
|
||||
tokio::pin!(poll);
|
||||
tokio::select! {
|
||||
result = &mut poll => result,
|
||||
() = &mut listener => {
|
||||
open_in_browser::<Sys, Reporter>(&canonical_url);
|
||||
// The keypress fires once; keep awaiting the poll.
|
||||
poll.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open `url`, downgrading any failure to a warning so a missing
|
||||
/// `xdg-open` (or equivalent) never interrupts the poll.
|
||||
fn open_in_browser<Sys, Reporter>(url: &str)
|
||||
where
|
||||
Sys: OpenUrl,
|
||||
Reporter: self::Reporter,
|
||||
{
|
||||
if let Err(error) = Sys::open_url(url) {
|
||||
global_warn::<Reporter>(&format!("Could not open browser automatically: {error}"));
|
||||
global_info::<Reporter>("Please open the URL shown above manually.");
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the canonical form of `auth_url` when it is an `http(s)` URL, or
|
||||
/// `None` otherwise. Guards `open` against `javascript:` / `file:` / other
|
||||
/// schemes in an untrusted registry response.
|
||||
fn canonical_http_url(auth_url: &str) -> Option<String> {
|
||||
let parsed = url::Url::parse(auth_url).ok()?;
|
||||
matches!(parsed.scheme(), "http" | "https").then(|| parsed.to_string())
|
||||
}
|
||||
299
pacquet/crates/network-web-auth/src/prompt_browser_open/tests.rs
Normal file
299
pacquet/crates/network-web-auth/src/prompt_browser_open/tests.rs
Normal file
@@ -0,0 +1,299 @@
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
future::{self, Future},
|
||||
io,
|
||||
pin::Pin,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter};
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::{sync::oneshot, task::LocalSet};
|
||||
|
||||
use super::prompt_browser_open;
|
||||
use crate::capabilities::{EnterKeyListener, OpenUrl, StdinIsTty};
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Outcome {
|
||||
Succeed,
|
||||
Fail,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static STDIN_TTY: Cell<bool> = const { Cell::new(true) };
|
||||
static LISTEN_OUTCOME: Cell<Outcome> = const { Cell::new(Outcome::Succeed) };
|
||||
static OPEN_OUTCOME: Cell<Outcome> = const { Cell::new(Outcome::Succeed) };
|
||||
static OPEN_CALLS: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
|
||||
static CLOSED: Cell<bool> = const { Cell::new(false) };
|
||||
static ENTER_TX: RefCell<Option<oneshot::Sender<()>>> = const { RefCell::new(None) };
|
||||
static EMITTED: RefCell<Vec<(LogLevel, String)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Fake;
|
||||
|
||||
impl StdinIsTty for Fake {
|
||||
fn stdin_is_tty() -> bool {
|
||||
STDIN_TTY.with(Cell::get)
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenUrl for Fake {
|
||||
fn open_url(url: &str) -> io::Result<()> {
|
||||
OPEN_CALLS.with(|calls| calls.borrow_mut().push(url.to_owned()));
|
||||
match OPEN_OUTCOME.with(Cell::get) {
|
||||
Outcome::Succeed => Ok(()),
|
||||
Outcome::Fail => Err(io::Error::other("xdg-open not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves when the test simulates an Enter keypress; sets the `CLOSED`
|
||||
/// flag on drop, standing in for `readline.Interface.close`.
|
||||
struct FakeEnterHandle {
|
||||
rx: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Future for FakeEnterHandle {
|
||||
type Output = ();
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
Pin::new(&mut self.get_mut().rx).poll(cx).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FakeEnterHandle {
|
||||
fn drop(&mut self) {
|
||||
CLOSED.with(|closed| closed.set(true));
|
||||
}
|
||||
}
|
||||
|
||||
impl EnterKeyListener for Fake {
|
||||
type Handle = FakeEnterHandle;
|
||||
|
||||
fn listen() -> io::Result<FakeEnterHandle> {
|
||||
match LISTEN_OUTCOME.with(Cell::get) {
|
||||
Outcome::Fail => Err(io::Error::other("setRawMode not supported")),
|
||||
Outcome::Succeed => {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
ENTER_TX.with(|cell| *cell.borrow_mut() = Some(tx));
|
||||
Ok(FakeEnterHandle { rx })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingReporter;
|
||||
|
||||
impl Reporter for RecordingReporter {
|
||||
fn emit(event: &LogEvent) {
|
||||
if let LogEvent::Global(GlobalLog { level, message }) = event {
|
||||
EMITTED.with(|emitted| emitted.borrow_mut().push((*level, message.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset() {
|
||||
STDIN_TTY.with(|tty| tty.set(true));
|
||||
LISTEN_OUTCOME.with(|outcome| outcome.set(Outcome::Succeed));
|
||||
OPEN_OUTCOME.with(|outcome| outcome.set(Outcome::Succeed));
|
||||
OPEN_CALLS.with(|calls| calls.borrow_mut().clear());
|
||||
CLOSED.with(|closed| closed.set(false));
|
||||
ENTER_TX.with(|cell| *cell.borrow_mut() = None);
|
||||
EMITTED.with(|emitted| emitted.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn simulate_enter() {
|
||||
if let Some(tx) = ENTER_TX.with(|cell| cell.borrow_mut().take()) {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_calls() -> Vec<String> {
|
||||
OPEN_CALLS.with(|calls| calls.borrow().clone())
|
||||
}
|
||||
|
||||
fn closed() -> bool {
|
||||
CLOSED.with(Cell::get)
|
||||
}
|
||||
|
||||
fn infos() -> Vec<String> {
|
||||
messages_at(LogLevel::Info)
|
||||
}
|
||||
|
||||
fn warns() -> Vec<String> {
|
||||
messages_at(LogLevel::Warn)
|
||||
}
|
||||
|
||||
fn messages_at(level: LogLevel) -> Vec<String> {
|
||||
EMITTED.with(|emitted| {
|
||||
emitted
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(emitted_level, _)| *emitted_level == level)
|
||||
.map(|(_, message)| message.clone())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll error type. Carries a message so the rejection tests can assert
|
||||
/// the propagated value.
|
||||
#[derive(Debug)]
|
||||
struct PollError(&'static str);
|
||||
|
||||
const AUTH_URL: &str = "https://example.com/auth";
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_the_poll_result_when_poll_completes_before_enter_keypress() {
|
||||
reset();
|
||||
|
||||
let token = prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
AUTH_URL,
|
||||
future::ready(Ok("my-token".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert_eq!(token, "my-token");
|
||||
assert!(closed(), "the listener should be closed");
|
||||
assert!(open_calls().is_empty(), "the browser must not be opened without a keypress");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opens_browser_when_enter_key_is_pressed_before_poll_completes() {
|
||||
reset();
|
||||
LocalSet::new()
|
||||
.run_until(async {
|
||||
let (poll_tx, poll_rx) = oneshot::channel::<Result<String, PollError>>();
|
||||
let handle = tokio::task::spawn_local(async move {
|
||||
let poll = async move { poll_rx.await.expect("poll resolved") };
|
||||
prompt_browser_open::<Fake, RecordingReporter, PollError, _>(AUTH_URL, poll).await
|
||||
});
|
||||
|
||||
// Let the prompt register its listener, then press Enter.
|
||||
tokio::task::yield_now().await;
|
||||
simulate_enter();
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert_eq!(open_calls(), vec![AUTH_URL.to_owned()]);
|
||||
|
||||
poll_tx.send(Ok("token-after-enter".to_owned())).expect("send poll result");
|
||||
let token = handle.await.expect("join").expect("a token");
|
||||
|
||||
assert_eq!(token, "token-after-enter");
|
||||
assert!(closed(), "the listener should be closed");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warns_and_continues_polling_when_open_fails() {
|
||||
reset();
|
||||
OPEN_OUTCOME.with(|outcome| outcome.set(Outcome::Fail));
|
||||
LocalSet::new()
|
||||
.run_until(async {
|
||||
let (poll_tx, poll_rx) = oneshot::channel::<Result<String, PollError>>();
|
||||
let handle = tokio::task::spawn_local(async move {
|
||||
let poll = async move { poll_rx.await.expect("poll resolved") };
|
||||
prompt_browser_open::<Fake, RecordingReporter, PollError, _>(AUTH_URL, poll).await
|
||||
});
|
||||
|
||||
tokio::task::yield_now().await;
|
||||
simulate_enter();
|
||||
tokio::task::yield_now().await;
|
||||
|
||||
assert!(
|
||||
warns().iter().any(|message| message.contains("xdg-open not found")),
|
||||
"open failure should warn, got {:?}",
|
||||
warns(),
|
||||
);
|
||||
assert!(infos().contains(&"Please open the URL shown above manually.".to_owned()));
|
||||
|
||||
poll_tx.send(Ok("tok".to_owned())).expect("send poll result");
|
||||
let token = handle.await.expect("join").expect("a token");
|
||||
assert_eq!(token, "tok");
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn warns_and_falls_back_to_plain_poll_when_listen_fails() {
|
||||
reset();
|
||||
LISTEN_OUTCOME.with(|outcome| outcome.set(Outcome::Fail));
|
||||
|
||||
let token = prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
AUTH_URL,
|
||||
future::ready(Ok("fallback-token".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert_eq!(token, "fallback-token");
|
||||
assert!(
|
||||
warns().iter().any(|message| message.contains("setRawMode not supported")),
|
||||
"listener setup failure should warn, got {:?}",
|
||||
warns(),
|
||||
);
|
||||
assert!(open_calls().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn falls_back_to_plain_poll_when_stdin_is_not_a_tty() {
|
||||
reset();
|
||||
STDIN_TTY.with(|tty| tty.set(false));
|
||||
|
||||
let token = prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
AUTH_URL,
|
||||
future::ready(Ok("plain-token".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert_eq!(token, "plain-token");
|
||||
assert!(open_calls().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shows_the_press_enter_message() {
|
||||
reset();
|
||||
|
||||
prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
AUTH_URL,
|
||||
future::ready(Ok("tok".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert!(infos().contains(&"Press ENTER to open the URL in your browser.".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn does_not_open_browser_for_non_http_auth_url() {
|
||||
for auth_url in ["javascript:alert(1)", "file:///etc/passwd", "not a url"] {
|
||||
reset();
|
||||
|
||||
let token = prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
auth_url,
|
||||
future::ready(Ok("tok".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect("a token");
|
||||
|
||||
assert_eq!(token, "tok");
|
||||
assert!(open_calls().is_empty(), "{auth_url} must not open a browser");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cleans_up_when_poll_rejects() {
|
||||
reset();
|
||||
|
||||
let error = prompt_browser_open::<Fake, RecordingReporter, PollError, _>(
|
||||
AUTH_URL,
|
||||
future::ready(Err(PollError("timeout"))),
|
||||
)
|
||||
.await
|
||||
.expect_err("poll rejected");
|
||||
|
||||
assert_eq!(error.0, "timeout");
|
||||
assert!(closed(), "the listener should be closed even when the poll rejects");
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use pacquet_diagnostics::miette::{self, Diagnostic};
|
||||
|
||||
/// Web-based authentication did not complete before the timeout.
|
||||
///
|
||||
/// Ports pnpm's `WebAuthTimeoutError`. The `code(...)` is part of the
|
||||
/// public contract (<https://pnpm.io/errors>). `start_time` / `end_time`
|
||||
/// are the Unix-epoch-millisecond [`Clock`](crate::Clock) readings that
|
||||
/// bracketed the poll, and `timeout` is the configured budget in
|
||||
/// milliseconds — the same three numbers pnpm's error carries.
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)]
|
||||
#[display("Web-based authentication timed out before it could be completed")]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_WEBAUTH_TIMEOUT),
|
||||
help(
|
||||
"Re-run this command and complete the authentication step in your browser before the time \
|
||||
limit is reached"
|
||||
)
|
||||
)]
|
||||
pub struct WebAuthTimeoutError {
|
||||
pub end_time: u64,
|
||||
pub start_time: u64,
|
||||
pub timeout: u64,
|
||||
}
|
||||
|
||||
impl WebAuthTimeoutError {
|
||||
#[must_use]
|
||||
pub fn new(end_time: u64, start_time: u64, timeout: u64) -> Self {
|
||||
WebAuthTimeoutError { end_time, start_time, timeout }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,32 @@
|
||||
use pacquet_diagnostics::miette::Diagnostic;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::WebAuthTimeoutError;
|
||||
|
||||
#[test]
|
||||
fn stores_end_time_start_time_and_timeout() {
|
||||
let err = WebAuthTimeoutError::new(310_000, 10_000, 300_000);
|
||||
assert_eq!(err.end_time, 310_000);
|
||||
assert_eq!(err.start_time, 10_000);
|
||||
assert_eq!(err.timeout, 300_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_webauth_timeout_code() {
|
||||
let err = WebAuthTimeoutError::new(0, 0, 0);
|
||||
assert_eq!(err.code().expect("a diagnostic code").to_string(), "ERR_PNPM_WEBAUTH_TIMEOUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn includes_a_hint_about_re_running_the_command() {
|
||||
let err = WebAuthTimeoutError::new(0, 0, 0);
|
||||
let help = err.help().expect("a help hint").to_string();
|
||||
assert!(help.contains("Re-run"), "help should mention re-running, got {help:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_a_descriptive_message() {
|
||||
let err = WebAuthTimeoutError::new(0, 0, 0);
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("timed out"), "message should mention timing out, got {message:?}");
|
||||
}
|
||||
252
pacquet/crates/network-web-auth/src/with_otp_handling.rs
Normal file
252
pacquet/crates/network-web-auth/src/with_otp_handling.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use pacquet_diagnostics::miette::{self, Diagnostic};
|
||||
use pacquet_reporter::Reporter;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
GenerateQrCodeError, WebAuthTimeoutError,
|
||||
capabilities::{
|
||||
Clock, EnterKeyListener, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty,
|
||||
WebAuthFetch,
|
||||
},
|
||||
generate_qr_code::generate_qr_code,
|
||||
global_log::{global_info, global_warn},
|
||||
poll_for_web_auth_token::{
|
||||
WebAuthFetchOptions, WebAuthTokenPollParams, poll_for_web_auth_token,
|
||||
},
|
||||
prompt_browser_open::prompt_browser_open,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
/// The `authUrl` / `doneUrl` an OTP challenge may carry. Both are optional
|
||||
/// because a registry may send neither (a classic OTP) or a malformed
|
||||
/// body. Ports TS `OtpErrorBody`.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct OtpErrorBody {
|
||||
pub auth_url: Option<String>,
|
||||
pub done_url: Option<String>,
|
||||
}
|
||||
|
||||
/// An EOTP challenge surfaced by an operation's error. Ports the
|
||||
/// `{ code: 'EOTP', body? }` shape pnpm detects with `isOtpError`.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct OtpChallenge {
|
||||
pub body: Option<OtpErrorBody>,
|
||||
}
|
||||
|
||||
/// Implemented by an operation's error type so [`with_otp_handling`] can
|
||||
/// detect an EOTP challenge and read its body. Ports TS `isOtpError` plus
|
||||
/// the `error.body` read: returning `Some` is the moral equivalent of
|
||||
/// `error.code === 'EOTP'`.
|
||||
pub trait OtpError {
|
||||
fn as_otp_challenge(&self) -> Option<OtpChallenge>;
|
||||
}
|
||||
|
||||
/// Synthetic EOTP error meant to be thrown by an operation passed to
|
||||
/// [`with_otp_handling`] and caught by it — never to propagate elsewhere.
|
||||
/// Ports TS `SyntheticOtpError`.
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Clone)]
|
||||
#[display(
|
||||
"This error was meant to be caught by `with_otp_handling`, not to propagate to other parts of \
|
||||
the code"
|
||||
)]
|
||||
pub struct SyntheticOtpError {
|
||||
body: Option<OtpErrorBody>,
|
||||
}
|
||||
|
||||
impl SyntheticOtpError {
|
||||
#[must_use]
|
||||
pub fn new(body: Option<OtpErrorBody>) -> Self {
|
||||
SyntheticOtpError { body }
|
||||
}
|
||||
|
||||
/// Build a challenge from an arbitrary JSON body, keeping only string
|
||||
/// `authUrl` / `doneUrl` fields and warning (via the `R: Reporter`
|
||||
/// global-warn seam) when either is present with a non-string type.
|
||||
/// Ports TS `SyntheticOtpError.fromUnknownBody`.
|
||||
#[must_use]
|
||||
pub fn from_unknown_body<Reporter: self::Reporter>(body: Option<&Value>) -> Self {
|
||||
let Some(Value::Object(map)) = body else {
|
||||
return SyntheticOtpError { body: None };
|
||||
};
|
||||
let auth_url = extract_url_field::<Reporter>(map, "authUrl");
|
||||
let done_url = extract_url_field::<Reporter>(map, "doneUrl");
|
||||
SyntheticOtpError { body: Some(OtpErrorBody { auth_url, done_url }) }
|
||||
}
|
||||
}
|
||||
|
||||
impl OtpError for SyntheticOtpError {
|
||||
fn as_otp_challenge(&self) -> Option<OtpChallenge> {
|
||||
Some(OtpChallenge { body: self.body.clone() })
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_url_field<Reporter: self::Reporter>(
|
||||
map: &serde_json::Map<String, Value>,
|
||||
field: &str,
|
||||
) -> Option<String> {
|
||||
match map.get(field) {
|
||||
None => None,
|
||||
Some(Value::String(value)) => Some(value.clone()),
|
||||
Some(other) => {
|
||||
global_warn::<Reporter>(&format!(
|
||||
"OTP error body: {field} has type {}, expected string",
|
||||
js_typeof(other),
|
||||
));
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JavaScript's `typeof` for a JSON value, used to mirror pnpm's warning
|
||||
/// text. Note `typeof null === 'object'` and `typeof [] === 'object'`.
|
||||
fn js_typeof(value: &Value) -> &'static str {
|
||||
match value {
|
||||
Value::Null | Value::Array(_) | Value::Object(_) => "object",
|
||||
Value::Bool(_) => "boolean",
|
||||
Value::Number(_) => "number",
|
||||
Value::String(_) => "string",
|
||||
}
|
||||
}
|
||||
|
||||
/// The registry required additional authentication but the terminal is not
|
||||
/// interactive. Ports pnpm's `OtpNonInteractiveError`
|
||||
/// (`ERR_PNPM_OTP_NON_INTERACTIVE`).
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)]
|
||||
#[display(
|
||||
"The registry requires additional authentication, but pnpm is not running in an interactive \
|
||||
terminal"
|
||||
)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_OTP_NON_INTERACTIVE),
|
||||
help(
|
||||
"Re-run this command in an interactive terminal to complete authentication, or provide the \
|
||||
--otp option if you are using a classic one-time password (OTP)"
|
||||
)
|
||||
)]
|
||||
pub struct OtpNonInteractiveError;
|
||||
|
||||
/// The registry asked for an OTP a second time after one was already
|
||||
/// supplied. Ports pnpm's `OtpSecondChallengeError`
|
||||
/// (`ERR_PNPM_OTP_SECOND_CHALLENGE`).
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)]
|
||||
#[display(
|
||||
"The registry requested a one-time password (OTP) a second time after one was already provided"
|
||||
)]
|
||||
#[diagnostic(
|
||||
code(ERR_PNPM_OTP_SECOND_CHALLENGE),
|
||||
help(
|
||||
"This is unexpected behavior from the registry. Try the command again later and, if the \
|
||||
issue persists, verify that your registry supports OTP-based authentication or contact \
|
||||
the registry administrator."
|
||||
)
|
||||
)]
|
||||
pub struct OtpSecondChallengeError;
|
||||
|
||||
/// Failure surface of [`with_otp_handling`]. `Operation` carries the
|
||||
/// caller's own error (the original challenge re-thrown, or a non-OTP
|
||||
/// failure from either operation attempt); the rest are this crate's
|
||||
/// errors.
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)]
|
||||
pub enum WithOtpError<Error: Diagnostic + 'static> {
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
Operation(Error),
|
||||
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
NonInteractive(OtpNonInteractiveError),
|
||||
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
SecondChallenge(OtpSecondChallengeError),
|
||||
|
||||
#[display("{_0}")]
|
||||
#[diagnostic(transparent)]
|
||||
Timeout(WebAuthTimeoutError),
|
||||
|
||||
#[display("{_0}")]
|
||||
Prompt(PromptError),
|
||||
|
||||
#[display("{_0}")]
|
||||
QrCode(GenerateQrCodeError),
|
||||
}
|
||||
|
||||
/// Run `operation`, transparently satisfying an OTP challenge if it raises
|
||||
/// one.
|
||||
///
|
||||
/// On the first [`OtpError`] the operation returns, this either drives the
|
||||
/// web-based authentication flow (when the challenge body carries both
|
||||
/// `authUrl` and `doneUrl`) or prompts for a classic OTP, then retries the
|
||||
/// operation once with the obtained one-time password. Any non-OTP error,
|
||||
/// or an OTP challenge with no usable code, propagates unchanged.
|
||||
///
|
||||
/// Ports pnpm's `withOtpHandling`.
|
||||
pub async fn with_otp_handling<Sys, Reporter, Token, Error, Operation>(
|
||||
fetch_options: WebAuthFetchOptions,
|
||||
mut operation: Operation,
|
||||
) -> Result<Token, WithOtpError<Error>>
|
||||
where
|
||||
Sys: Clock
|
||||
+ Sleep
|
||||
+ WebAuthFetch
|
||||
+ StdinIsTty
|
||||
+ StdoutIsTty
|
||||
+ EnterKeyListener
|
||||
+ OpenUrl
|
||||
+ PromptOtp,
|
||||
Reporter: self::Reporter,
|
||||
Error: OtpError + Diagnostic + 'static,
|
||||
Operation: AsyncFnMut(Option<&str>) -> Result<Token, Error>,
|
||||
{
|
||||
let error = match operation(None).await {
|
||||
Ok(value) => return Ok(value),
|
||||
Err(error) => error,
|
||||
};
|
||||
|
||||
let Some(challenge) = error.as_otp_challenge() else {
|
||||
return Err(WithOtpError::Operation(error));
|
||||
};
|
||||
|
||||
if !Sys::stdin_is_tty() || !Sys::stdout_is_tty() {
|
||||
return Err(WithOtpError::NonInteractive(OtpNonInteractiveError));
|
||||
}
|
||||
|
||||
let otp = match challenge.body {
|
||||
Some(OtpErrorBody { auth_url: Some(auth_url), done_url: Some(done_url) }) => {
|
||||
let qr_code = generate_qr_code(&auth_url).map_err(WithOtpError::QrCode)?;
|
||||
global_info::<Reporter>(&format!(
|
||||
"Authenticate your account at:\n{auth_url}\n\n{qr_code}",
|
||||
));
|
||||
let poll = poll_for_web_auth_token::<Sys>(WebAuthTokenPollParams {
|
||||
done_url,
|
||||
fetch_options,
|
||||
timeout_ms: None,
|
||||
});
|
||||
prompt_browser_open::<Sys, Reporter, _, _>(&auth_url, poll)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(WithOtpError::Timeout)?
|
||||
}
|
||||
_ => match Sys::input("This operation requires a one-time password.\nEnter OTP:").await {
|
||||
Ok(value) => value.filter(|otp| !otp.is_empty()),
|
||||
// The user aborted the prompt: re-throw the original challenge,
|
||||
// matching pnpm's `ExitPromptError` handling.
|
||||
Err(PromptError::Cancelled) => return Err(WithOtpError::Operation(error)),
|
||||
Err(other) => return Err(WithOtpError::Prompt(other)),
|
||||
},
|
||||
};
|
||||
|
||||
let Some(otp) = otp else {
|
||||
return Err(WithOtpError::Operation(error));
|
||||
};
|
||||
|
||||
match operation(Some(&otp)).await {
|
||||
Ok(value) => Ok(value),
|
||||
Err(retry_error) if retry_error.as_otp_challenge().is_some() => {
|
||||
Err(WithOtpError::SecondChallenge(OtpSecondChallengeError))
|
||||
}
|
||||
Err(retry_error) => Err(WithOtpError::Operation(retry_error)),
|
||||
}
|
||||
}
|
||||
623
pacquet/crates/network-web-auth/src/with_otp_handling/tests.rs
Normal file
623
pacquet/crates/network-web-auth/src/with_otp_handling/tests.rs
Normal file
@@ -0,0 +1,623 @@
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
future::{self, Future},
|
||||
io,
|
||||
pin::Pin,
|
||||
rc::Rc,
|
||||
task::{Context, Poll},
|
||||
};
|
||||
|
||||
use pacquet_diagnostics::miette::{self, Diagnostic};
|
||||
use pacquet_reporter::{GlobalLog, LogEvent, LogLevel, Reporter};
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use super::{
|
||||
OtpChallenge, OtpError, OtpErrorBody, SyntheticOtpError, WithOtpError, with_otp_handling,
|
||||
};
|
||||
use crate::{
|
||||
capabilities::{
|
||||
Clock, EnterKeyListener, OpenUrl, PromptError, PromptOtp, Sleep, StdinIsTty, StdoutIsTty,
|
||||
WebAuthFetch, WebAuthFetchError,
|
||||
},
|
||||
poll_for_web_auth_token::{WebAuthFetchOptions, WebAuthFetchResponse},
|
||||
};
|
||||
|
||||
/// An operation error that is either an EOTP challenge or a plain failure,
|
||||
/// so a single `Error` type covers both the OTP and non-OTP test paths.
|
||||
#[derive(Debug, derive_more::Display, derive_more::Error, Diagnostic)]
|
||||
enum TestError {
|
||||
#[display("otp challenge")]
|
||||
Otp { body: Option<OtpErrorBody> },
|
||||
#[display("{_0}")]
|
||||
Other(#[error(not(source))] String),
|
||||
}
|
||||
|
||||
impl OtpError for TestError {
|
||||
fn as_otp_challenge(&self) -> Option<OtpChallenge> {
|
||||
match self {
|
||||
TestError::Otp { body } => Some(OtpChallenge { body: body.clone() }),
|
||||
TestError::Other(_) => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the [`PromptOtp`] fake returns for the classic-OTP prompt.
|
||||
enum InputResponse {
|
||||
Value(Option<String>),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum SleepBehavior {
|
||||
NoAdvance,
|
||||
AdvanceByFixed(u64),
|
||||
}
|
||||
|
||||
type FetchScript = Box<dyn FnMut() -> Result<WebAuthFetchResponse, WebAuthFetchError>>;
|
||||
|
||||
thread_local! {
|
||||
static STDIN_TTY: Cell<bool> = const { Cell::new(true) };
|
||||
static STDOUT_TTY: Cell<bool> = const { Cell::new(true) };
|
||||
static TIME: Cell<u64> = const { Cell::new(0) };
|
||||
static SLEEP_BEHAVIOR: Cell<SleepBehavior> = const { Cell::new(SleepBehavior::NoAdvance) };
|
||||
static FETCH: RefCell<Option<FetchScript>> = const { RefCell::new(None) };
|
||||
static INPUT: RefCell<InputResponse> = const { RefCell::new(InputResponse::Value(None)) };
|
||||
static ENTER_TX: RefCell<Option<oneshot::Sender<()>>> = const { RefCell::new(None) };
|
||||
static EMITTED: RefCell<Vec<(LogLevel, String)>> = const { RefCell::new(Vec::new()) };
|
||||
}
|
||||
|
||||
struct Fake;
|
||||
|
||||
impl StdinIsTty for Fake {
|
||||
fn stdin_is_tty() -> bool {
|
||||
STDIN_TTY.with(Cell::get)
|
||||
}
|
||||
}
|
||||
|
||||
impl StdoutIsTty for Fake {
|
||||
fn stdout_is_tty() -> bool {
|
||||
STDOUT_TTY.with(Cell::get)
|
||||
}
|
||||
}
|
||||
|
||||
impl Clock for Fake {
|
||||
fn now_ms() -> u64 {
|
||||
TIME.with(Cell::get)
|
||||
}
|
||||
}
|
||||
|
||||
impl Sleep for Fake {
|
||||
fn sleep_ms(ms: u64) -> impl Future<Output = ()> {
|
||||
let _ = ms;
|
||||
if let SleepBehavior::AdvanceByFixed(jump) = SLEEP_BEHAVIOR.with(Cell::get) {
|
||||
TIME.with(|time| time.set(time.get().saturating_add(jump)));
|
||||
}
|
||||
future::ready(())
|
||||
}
|
||||
}
|
||||
|
||||
impl WebAuthFetch for Fake {
|
||||
fn fetch(
|
||||
_url: &str,
|
||||
_options: &WebAuthFetchOptions,
|
||||
) -> impl Future<Output = Result<WebAuthFetchResponse, WebAuthFetchError>> {
|
||||
let result = FETCH
|
||||
.with(|fetch| (fetch.borrow_mut().as_mut().expect("a fetch script must be set"))());
|
||||
future::ready(result)
|
||||
}
|
||||
}
|
||||
|
||||
impl PromptOtp for Fake {
|
||||
fn input(_message: &str) -> impl Future<Output = Result<Option<String>, PromptError>> {
|
||||
let response = INPUT.with(|input| match &*input.borrow() {
|
||||
InputResponse::Value(value) => Ok(value.clone()),
|
||||
InputResponse::Cancelled => Err(PromptError::Cancelled),
|
||||
});
|
||||
future::ready(response)
|
||||
}
|
||||
}
|
||||
|
||||
impl OpenUrl for Fake {
|
||||
fn open_url(_url: &str) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Never resolves on its own — in these tests the web-auth poll always
|
||||
/// wins or times out before any Enter keypress.
|
||||
struct PendingEnterHandle {
|
||||
rx: oneshot::Receiver<()>,
|
||||
}
|
||||
|
||||
impl Future for PendingEnterHandle {
|
||||
type Output = ();
|
||||
|
||||
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<()> {
|
||||
Pin::new(&mut self.get_mut().rx).poll(cx).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
impl EnterKeyListener for Fake {
|
||||
type Handle = PendingEnterHandle;
|
||||
|
||||
fn listen() -> io::Result<PendingEnterHandle> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
ENTER_TX.with(|cell| *cell.borrow_mut() = Some(tx));
|
||||
Ok(PendingEnterHandle { rx })
|
||||
}
|
||||
}
|
||||
|
||||
struct RecordingReporter;
|
||||
|
||||
impl Reporter for RecordingReporter {
|
||||
fn emit(event: &LogEvent) {
|
||||
if let LogEvent::Global(GlobalLog { level, message }) = event {
|
||||
EMITTED.with(|emitted| emitted.borrow_mut().push((*level, message.clone())));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Panics on any global message — the stand-in for the TS `globalWarn`
|
||||
/// that throws when a test expects no warning.
|
||||
struct UnexpectedReporter;
|
||||
|
||||
impl Reporter for UnexpectedReporter {
|
||||
fn emit(event: &LogEvent) {
|
||||
if let LogEvent::Global(GlobalLog { message, .. }) = event {
|
||||
panic!("unexpected global message: {message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset() {
|
||||
STDIN_TTY.with(|tty| tty.set(true));
|
||||
STDOUT_TTY.with(|tty| tty.set(true));
|
||||
TIME.with(|time| time.set(0));
|
||||
SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::NoAdvance));
|
||||
FETCH.with(|fetch| *fetch.borrow_mut() = None);
|
||||
INPUT.with(|input| *input.borrow_mut() = InputResponse::Value(None));
|
||||
ENTER_TX.with(|cell| *cell.borrow_mut() = None);
|
||||
EMITTED.with(|emitted| emitted.borrow_mut().clear());
|
||||
}
|
||||
|
||||
fn set_input(response: InputResponse) {
|
||||
INPUT.with(|input| *input.borrow_mut() = response);
|
||||
}
|
||||
|
||||
fn set_fetch(script: FetchScript) {
|
||||
FETCH.with(|fetch| *fetch.borrow_mut() = Some(script));
|
||||
}
|
||||
|
||||
fn infos() -> Vec<String> {
|
||||
messages_at(LogLevel::Info)
|
||||
}
|
||||
|
||||
fn warns() -> Vec<String> {
|
||||
messages_at(LogLevel::Warn)
|
||||
}
|
||||
|
||||
fn messages_at(level: LogLevel) -> Vec<String> {
|
||||
EMITTED.with(|emitted| {
|
||||
emitted
|
||||
.borrow()
|
||||
.iter()
|
||||
.filter(|(emitted_level, _)| *emitted_level == level)
|
||||
.map(|(_, message)| message.clone())
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn ok_202() -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse { ok: true, status: 202, retry_after: None, body: "{}".to_owned() }
|
||||
}
|
||||
|
||||
fn ok_token(token: &str) -> WebAuthFetchResponse {
|
||||
WebAuthFetchResponse {
|
||||
ok: true,
|
||||
status: 200,
|
||||
retry_after: None,
|
||||
body: json!({ "token": token }).to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn web_auth_body() -> Option<OtpErrorBody> {
|
||||
Some(OtpErrorBody {
|
||||
auth_url: Some("https://registry.npmjs.org/auth/abc".to_owned()),
|
||||
done_url: Some("https://registry.npmjs.org/auth/abc/done".to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returns_the_result_when_the_operation_succeeds_without_otp() {
|
||||
reset();
|
||||
|
||||
let result = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Ok("success".to_owned()),
|
||||
)
|
||||
.await
|
||||
.expect("a result");
|
||||
|
||||
assert_eq!(result, "success");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_non_otp_errors_as_is() {
|
||||
reset();
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Other("network error".to_owned())),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(
|
||||
matches!(&error, WithOtpError::Operation(TestError::Other(message)) if message == "network error"),
|
||||
"expected the original error, got {error:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_non_interactive_error_when_stdin_is_not_interactive() {
|
||||
reset();
|
||||
STDIN_TTY.with(|tty| tty.set(false));
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::NonInteractive(_)), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn throws_non_interactive_error_when_stdout_is_not_interactive() {
|
||||
reset();
|
||||
STDOUT_TTY.with(|tty| tty.set(false));
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::NonInteractive(_)), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_prompts_for_otp_and_retries_operation() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some("654321".to_owned())));
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
|
||||
let result = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |otp| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() == 1 {
|
||||
Err(TestError::Otp { body: None })
|
||||
} else {
|
||||
assert_eq!(otp, Some("654321"));
|
||||
Ok("ok".to_owned())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a result");
|
||||
|
||||
assert_eq!(result, "ok");
|
||||
assert_eq!(calls.get(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_throws_second_challenge_error_if_retry_also_requires_otp() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some("123456".to_owned())));
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::SecondChallenge(_)), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_throws_non_otp_errors_from_the_retry_as_is() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some("123456".to_owned())));
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |_otp| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() == 1 {
|
||||
Err(TestError::Otp { body: None })
|
||||
} else {
|
||||
Err(TestError::Other("server error".to_owned()))
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(
|
||||
matches!(&error, WithOtpError::Operation(TestError::Other(message)) if message == "server error"),
|
||||
"got {error:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_re_throws_the_original_otp_error_when_prompt_returns_empty() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some(String::new())));
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_re_throws_the_original_otp_error_when_prompt_returns_none() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(None));
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn classic_flow_re_throws_the_original_otp_error_when_prompt_is_cancelled() {
|
||||
reset();
|
||||
set_input(InputResponse::Cancelled);
|
||||
|
||||
let error = with_otp_handling::<Fake, UnexpectedReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async |_otp| Err(TestError::Otp { body: None }),
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::Operation(TestError::Otp { .. })), "got {error:?}");
|
||||
}
|
||||
|
||||
/// Unlike the TS test, which omits `createReadlineInterface` so the prompt
|
||||
/// stays silent, pacquet's listener is always available — so the flow also
|
||||
/// emits the "Press ENTER" line. The assertion checks that the auth URL
|
||||
/// was surfaced and the token round-tripped, not the exact message count.
|
||||
#[tokio::test]
|
||||
async fn web_auth_flow_polls_done_url_and_uses_returned_token() {
|
||||
reset();
|
||||
let fetch_calls = Rc::new(Cell::new(0));
|
||||
let fetch_counter = Rc::clone(&fetch_calls);
|
||||
set_fetch(Box::new(move || {
|
||||
fetch_counter.set(fetch_counter.get() + 1);
|
||||
Ok(if fetch_counter.get() < 3 { ok_202() } else { ok_token("web-token-123") })
|
||||
}));
|
||||
let op_calls = Rc::new(Cell::new(0));
|
||||
let op_counter = Rc::clone(&op_calls);
|
||||
|
||||
let result = with_otp_handling::<Fake, RecordingReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |otp| {
|
||||
op_counter.set(op_counter.get() + 1);
|
||||
if op_counter.get() == 1 {
|
||||
Err(TestError::Otp { body: web_auth_body() })
|
||||
} else {
|
||||
assert_eq!(otp, Some("web-token-123"));
|
||||
Ok("published".to_owned())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a result");
|
||||
|
||||
assert_eq!(result, "published");
|
||||
assert_eq!(op_calls.get(), 2);
|
||||
assert_eq!(fetch_calls.get(), 3);
|
||||
assert!(
|
||||
infos().iter().any(|message| message.contains("https://registry.npmjs.org/auth/abc")),
|
||||
"the auth URL should be surfaced, got {:?}",
|
||||
infos(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_auth_flow_falls_back_to_classic_prompt_when_only_auth_url_is_present() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some("manual-code".to_owned())));
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
|
||||
let result = with_otp_handling::<Fake, RecordingReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |otp| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() == 1 {
|
||||
Err(TestError::Otp {
|
||||
body: Some(OtpErrorBody {
|
||||
auth_url: Some("https://registry.npmjs.org/auth/abc".to_owned()),
|
||||
done_url: None,
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
assert_eq!(otp, Some("manual-code"));
|
||||
Ok("done".to_owned())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a result");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_auth_flow_falls_back_to_classic_prompt_when_only_done_url_is_present() {
|
||||
reset();
|
||||
set_input(InputResponse::Value(Some("manual-code".to_owned())));
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
|
||||
let result = with_otp_handling::<Fake, RecordingReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |otp| {
|
||||
counter.set(counter.get() + 1);
|
||||
if counter.get() == 1 {
|
||||
Err(TestError::Otp {
|
||||
body: Some(OtpErrorBody {
|
||||
auth_url: None,
|
||||
done_url: Some("https://registry.npmjs.org/auth/abc/done".to_owned()),
|
||||
}),
|
||||
})
|
||||
} else {
|
||||
assert_eq!(otp, Some("manual-code"));
|
||||
Ok("done".to_owned())
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect("a result");
|
||||
|
||||
assert_eq!(result, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn web_auth_flow_throws_timeout_error_when_polling_times_out() {
|
||||
reset();
|
||||
SLEEP_BEHAVIOR.with(|behavior| behavior.set(SleepBehavior::AdvanceByFixed(6 * 60 * 1000)));
|
||||
set_fetch(Box::new(|| Ok(ok_202())));
|
||||
let calls = Rc::new(Cell::new(0));
|
||||
let counter = Rc::clone(&calls);
|
||||
|
||||
let error = with_otp_handling::<Fake, RecordingReporter, String, TestError, _>(
|
||||
WebAuthFetchOptions::default(),
|
||||
async move |_otp| {
|
||||
counter.set(counter.get() + 1);
|
||||
assert_eq!(counter.get(), 1, "the operation must not be retried after a timeout");
|
||||
Err(TestError::Otp { body: web_auth_body() })
|
||||
},
|
||||
)
|
||||
.await
|
||||
.expect_err("an error");
|
||||
|
||||
assert!(matches!(error, WithOtpError::Timeout(_)), "got {error:?}");
|
||||
assert!(
|
||||
infos().iter().any(|message| message.contains("https://registry.npmjs.org/auth/abc")),
|
||||
"the auth URL should be surfaced, got {:?}",
|
||||
infos(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_otp_error_is_an_otp_challenge() {
|
||||
let error = SyntheticOtpError::new(web_auth_body());
|
||||
assert!(error.as_otp_challenge().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synthetic_otp_error_stores_body() {
|
||||
let body = OtpErrorBody {
|
||||
auth_url: Some("https://example.com/auth".to_owned()),
|
||||
done_url: Some("https://example.com/done".to_owned()),
|
||||
};
|
||||
let error = SyntheticOtpError::new(Some(body.clone()));
|
||||
assert_eq!(error.as_otp_challenge().expect("a challenge").body, Some(body));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_extracts_valid_string_auth_url_and_done_url() {
|
||||
let error = SyntheticOtpError::from_unknown_body::<UnexpectedReporter>(Some(
|
||||
&json!({ "authUrl": "https://example.com/auth", "doneUrl": "https://example.com/done" }),
|
||||
));
|
||||
assert_eq!(
|
||||
error.as_otp_challenge().expect("a challenge").body,
|
||||
Some(OtpErrorBody {
|
||||
auth_url: Some("https://example.com/auth".to_owned()),
|
||||
done_url: Some("https://example.com/done".to_owned()),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_returns_no_body_when_body_is_null() {
|
||||
let error = SyntheticOtpError::from_unknown_body::<UnexpectedReporter>(Some(&json!(null)));
|
||||
assert_eq!(error.as_otp_challenge().expect("a challenge").body, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_returns_no_body_when_body_is_not_an_object() {
|
||||
let error =
|
||||
SyntheticOtpError::from_unknown_body::<UnexpectedReporter>(Some(&json!("not an object")));
|
||||
assert_eq!(error.as_otp_challenge().expect("a challenge").body, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_warns_when_auth_url_has_wrong_type() {
|
||||
reset();
|
||||
let error = SyntheticOtpError::from_unknown_body::<RecordingReporter>(Some(
|
||||
&json!({ "authUrl": 123, "doneUrl": "https://example.com/done" }),
|
||||
));
|
||||
assert!(warns().iter().any(|message| message.contains("authUrl")), "got {:?}", warns());
|
||||
let body = error.as_otp_challenge().expect("a challenge").body.expect("a body");
|
||||
assert_eq!(body.auth_url, None);
|
||||
assert_eq!(body.done_url, Some("https://example.com/done".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_warns_when_done_url_has_wrong_type() {
|
||||
reset();
|
||||
let error = SyntheticOtpError::from_unknown_body::<RecordingReporter>(Some(
|
||||
&json!({ "authUrl": "https://example.com/auth", "doneUrl": true }),
|
||||
));
|
||||
assert!(warns().iter().any(|message| message.contains("doneUrl")), "got {:?}", warns());
|
||||
let body = error.as_otp_challenge().expect("a challenge").body.expect("a body");
|
||||
assert_eq!(body.auth_url, Some("https://example.com/auth".to_owned()));
|
||||
assert_eq!(body.done_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_warns_for_both_when_both_have_wrong_types() {
|
||||
reset();
|
||||
let error = SyntheticOtpError::from_unknown_body::<RecordingReporter>(Some(
|
||||
&json!({ "authUrl": 42, "doneUrl": false }),
|
||||
));
|
||||
assert!(warns().iter().any(|message| message.contains("authUrl")), "got {:?}", warns());
|
||||
assert!(warns().iter().any(|message| message.contains("doneUrl")), "got {:?}", warns());
|
||||
let body = error.as_otp_challenge().expect("a challenge").body.expect("a body");
|
||||
assert_eq!(body.auth_url, None);
|
||||
assert_eq!(body.done_url, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_unknown_body_returns_empty_body_when_no_auth_url_or_done_url() {
|
||||
let error = SyntheticOtpError::from_unknown_body::<UnexpectedReporter>(Some(
|
||||
&json!({ "something": "else" }),
|
||||
));
|
||||
assert_eq!(
|
||||
error.as_otp_challenge().expect("a challenge").body,
|
||||
Some(OtpErrorBody { auth_url: None, done_url: None }),
|
||||
);
|
||||
}
|
||||
@@ -205,6 +205,17 @@ pub enum LogEvent {
|
||||
#[serde(rename = "pnpm")]
|
||||
Pnpm(PnpmLog),
|
||||
|
||||
/// Global-logger message (`name: "pnpm:global"`). Mirrors pnpm's
|
||||
/// [`globalInfo` / `globalWarn`](https://github.com/pnpm/pnpm/blob/fc2f33912e/pnpm11/core/logger/src/logger.ts#L15-L23),
|
||||
/// which write to a `bole('pnpm:global')` logger with just a message
|
||||
/// string — no `prefix`, unlike [`LogEvent::Pnpm`]. The interactive
|
||||
/// web-authentication flow (`pacquet-network-web-auth`) emits on this
|
||||
/// channel to surface the auth URL / QR code and the browser-open
|
||||
/// prompts. `@pnpm/cli.default-reporter` routes these into the "other"
|
||||
/// log stream.
|
||||
#[serde(rename = "pnpm:global")]
|
||||
Global(GlobalLog),
|
||||
|
||||
/// One per `context.log(...)` call a pnpmfile hook makes while it
|
||||
/// runs (`pnpm:hook`). `readPackage` and `afterAllResolved` hooks
|
||||
/// receive a `context` whose `log` forwards here, so a pnpmfile can
|
||||
@@ -799,6 +810,15 @@ pub struct PnpmLog {
|
||||
pub prefix: String,
|
||||
}
|
||||
|
||||
/// Global-channel (`name: "pnpm:global"`) payload. Carries only a
|
||||
/// severity and a message — pnpm's `bole('pnpm:global')` logger takes a
|
||||
/// bare string, with no `prefix`, so this struct has none either.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct GlobalLog {
|
||||
pub level: LogLevel,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// `pnpm:hook` payload. Field names match pnpm's `HookMessage` so
|
||||
/// `@pnpm/cli.default-reporter` accepts the record unchanged. `from`
|
||||
/// is the pnpmfile that defined the hook, `hook` is the hook name
|
||||
|
||||
@@ -6,11 +6,11 @@ use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
AddedRoot, BrokenModulesLog, ContextLog, DependencyType, Envelope, FetchingProgressLog,
|
||||
FetchingProgressMessage, GetHostName, HookLog, Host, IgnoredScriptsLog, LifecycleLog,
|
||||
LifecycleMessage, LifecycleStdio, LockfileVerificationLog, LockfileVerificationMessage,
|
||||
LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog, PackageManifestLog,
|
||||
PackageManifestMessage, PnpmLog, ProgressLog, ProgressMessage, RemovedRoot, Reporter,
|
||||
RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter,
|
||||
FetchingProgressMessage, GetHostName, GlobalLog, HookLog, Host, IgnoredScriptsLog,
|
||||
LifecycleLog, LifecycleMessage, LifecycleStdio, LockfileVerificationLog,
|
||||
LockfileVerificationMessage, LogEvent, LogLevel, PackageImportMethod, PackageImportMethodLog,
|
||||
PackageManifestLog, PackageManifestMessage, PnpmLog, ProgressLog, ProgressMessage, RemovedRoot,
|
||||
Reporter, RequestRetryError, RequestRetryLog, RootLog, RootMessage, SilentReporter,
|
||||
SkippedOptionalDependencyLog, SkippedOptionalPackage, SkippedOptionalReason, Stage, StageLog,
|
||||
StatsLog, StatsMessage, SummaryLog,
|
||||
};
|
||||
@@ -121,6 +121,33 @@ fn pnpm_event_matches_pnpm_wire_shape() {
|
||||
assert_eq!(json["prefix"], "/some/project");
|
||||
}
|
||||
|
||||
/// Global-channel (`name: "pnpm:global"`) log carries the
|
||||
/// `pnpm:global` channel name and a bare `message` with no `prefix` —
|
||||
/// matching pnpm's `bole('pnpm:global')` writes. A `prefix` field would
|
||||
/// diverge from the upstream shape `@pnpm/cli.default-reporter` parses.
|
||||
#[test]
|
||||
fn global_event_matches_pnpm_wire_shape() {
|
||||
let event = LogEvent::Global(GlobalLog {
|
||||
level: LogLevel::Info,
|
||||
message: "Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc".to_string(),
|
||||
});
|
||||
let envelope = Envelope { time: 1_700_000_000_000, hostname: "host", pid: 4242, event: &event };
|
||||
|
||||
let json: Value = envelope
|
||||
.pipe_ref(serde_json::to_string)
|
||||
.expect("serialize envelope")
|
||||
.pipe_as_ref(serde_json::from_str)
|
||||
.expect("parse JSON");
|
||||
|
||||
assert_eq!(json["name"], "pnpm:global");
|
||||
assert_eq!(json["level"], "info");
|
||||
assert_eq!(
|
||||
json["message"],
|
||||
"Authenticate your account at:\nhttps://registry.npmjs.org/auth/abc",
|
||||
);
|
||||
assert!(json.get("prefix").is_none(), "pnpm:global must not carry a prefix, got {json:?}");
|
||||
}
|
||||
|
||||
/// Hook log (`name: "pnpm:hook"`) carries the `from` / `hook` /
|
||||
/// `message` / `prefix` fields pnpm's `hookLogger` emits, at the
|
||||
/// `debug` level the hook-context logger uses. `@pnpm/cli.default-reporter`
|
||||
|
||||
Reference in New Issue
Block a user