diff --git a/rust/src/auth.rs b/rust/src/auth.rs index 8580433..eece1dc 100644 --- a/rust/src/auth.rs +++ b/rust/src/auth.rs @@ -5,6 +5,7 @@ use cli_engine::{ }; use crate::environments::{self, ResolvedEnv}; +use crate::pat::{self, PatEntry}; /// Single auth provider that dispatches to env-specific PKCE providers. /// @@ -49,6 +50,25 @@ fn build_provider(env: &ResolvedEnv) -> PkceAuthProvider { .with_redirect_uri(environments::REDIRECT_URI) } +/// Build a `Credential` from a PAT entry. +/// +/// The PAT is opaque to the CLI; the gateway exchanges it for an OAuth access +/// token. `identity` is set to a display name since the token itself carries no +/// readable subject claim. +fn pat_credential(env: &str, entry: &PatEntry) -> Credential { + Credential { + token: entry.token.clone(), + provider: pat::PROVIDER.to_owned(), + env: env.to_owned(), + identity: if entry.name.is_empty() { + "PAT".to_owned() + } else { + format!("PAT ({})", entry.name) + }, + ..Credential::default() + } +} + /// Emit (at debug level) the OAuth parameters that will be used for login and /// the code→token exchange. cli-engine builds the actual token request, so this /// is the CLI's single point of visibility into the client id / endpoints that @@ -91,23 +111,37 @@ impl AuthProvider for GoDaddyAuthProvider { } async fn get_credential(&self, env: &str, command: &str, tier: &str) -> Result { + if let Some(entry) = pat::resolve_pat(env).await { + return Ok(pat_credential(env, &entry)); + } let provider = self.provider_for(env)?; provider.get_credential(env, command, tier).await } async fn get_credential_for(&self, req: &CredentialRequest<'_>) -> Result { - // Forward to the env's PKCE provider, which performs OAuth scope step-up - // when the cached token lacks the command's required scopes. + // PATs have fixed scopes; the gateway enforces them. Use a PAT when one is + // configured, otherwise fall back to PKCE OAuth scope step-up. + if let Some(entry) = pat::resolve_pat(req.env).await { + return Ok(pat_credential(req.env, &entry)); + } let provider = self.provider_for(req.env)?; provider.get_credential_for(req).await } async fn status(&self, env: &str) -> Result { + if let Some(entry) = pat::resolve_pat(env).await { + return Ok(pat_credential(env, &entry)); + } let provider = self.provider_for(env)?; provider.status(env).await } async fn logout(&self, env: &str) -> Result<()> { + // Remove any stored PAT for the environment; ignore errors because the + // OAuth logout that follows is authoritative and PAT may not exist. + if let Err(err) = pat::delete_pat(env).await { + tracing::debug!(env, error = %err, "ignoring PAT delete error during logout"); + } let provider = self.provider_for(env)?; provider.logout(env).await } @@ -119,13 +153,20 @@ impl AuthProvider for GoDaddyAuthProvider { // warning) on a malformed local config, so this never fails wholesale. let listable = environments::listable().map_err(|e| CliCoreError::message(e.to_string()))?; - let mut envs = Vec::new(); + let mut envs = std::collections::BTreeSet::new(); + match pat::registry_envs().await { + Ok(pats) => envs.extend(pats), + Err(err) => { + tracing::warn!( + error = %err, + "failed to load PAT registry while listing environments; continuing" + ); + } + } for resolved in listable { let provider = build_provider(&resolved); envs.extend(provider.list_environments().await.unwrap_or_default()); } - envs.sort(); - envs.dedup(); - Ok(envs) + Ok(envs.into_iter().collect()) } } diff --git a/rust/src/main.rs b/rust/src/main.rs index ed28cfb..df324f8 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -11,6 +11,7 @@ mod environments; mod extension; mod hosting; mod output_schema; +mod pat; mod payments; mod quote_cache; mod scopes; @@ -45,7 +46,7 @@ async fn main() -> ExitCode { • hosting — manage Node.js PaaS applications (create, upload, deploy)\n \ • payments — manage the payment methods used for purchases\n\ \n\ - Most commands need authentication; run `gddy auth login` first (or just run a\n\ + Most commands need authentication; run `gddy auth login` first, or use a PAT via `gddy pat add` / `GDDY_PAT` for non-interactive workflows (or just run a\n\ command and follow the prompt). Use `--env` to target an environment and\n\ `gddy tree` to see every command. New here? Try `gddy domain available `.", ) @@ -93,6 +94,7 @@ async fn main() -> ExitCode { .with_module(domain::module()) .with_module(env::module()) .with_module(hosting::module()) + .with_module(pat::module()) .with_module(payments::module()) .with_module(webhook::module()), ); diff --git a/rust/src/pat/guides/auth.md b/rust/src/pat/guides/auth.md new file mode 100644 index 0000000..95e52c5 --- /dev/null +++ b/rust/src/pat/guides/auth.md @@ -0,0 +1,108 @@ +# GoDaddy CLI authentication + +The GoDaddy CLI supports two ways to authenticate: + +- **OAuth via `gddy auth login`** — interactive, browser-based login. Best for local development. +- **Personal Access Token (PAT) via `gddy pat add`** — non-interactive, long-lived token. Best for CI/CD, scripts, and headless environments. + +Both can be configured at the same time. Per environment, the CLI uses a PAT when one is available; otherwise it falls back to OAuth and opens a browser. + +## Interactive OAuth + +Run: + +```sh +gddy auth login --env prod +``` + +Your browser opens to the GoDaddy login screen. After you approve the CLI, an access token is stored in your OS keychain (or a protected file fallback) and reused until it expires. If a command needs wider scopes, the CLI may re-prompt through the browser. + +## Personal Access Tokens + +### Creating a PAT + +1. Sign in to the GoDaddy Developer Portal. +2. Open **Personal Access Tokens**. +3. Choose a name, the scopes you need, and an expiration (up to 365 days). +4. Copy the plaintext token immediately — it is shown only once. + +The token looks like: + +```text +gd_pat__<8-hex-crc> +``` + +### Storing a PAT in the CLI + +Read the token from stdin so it does not appear in shell history: + +```sh +echo 'gd_pat_...' | gddy pat add --env prod "CI token" +``` + +Alternatively, pass it explicitly: + +```sh +gddy pat add --env prod --token 'gd_pat_...' "CI token" +``` + +PATs are saved to your platform's `gddy` configuration directory (e.g. `~/.config/gddy/pat.toml` on Linux) with owner-only permissions where the platform supports it (`0600`). + +### Listing and removing PATs + +List stored PATs (only the last four characters are shown): + +```sh +gddy pat list +``` + +Remove the PAT for an environment: + +```sh +gddy pat remove --env prod +``` + +Removing the PAT from the CLI does **not** revoke it in the Developer Portal. + +### Using PATs in CI + +Instead of storing a PAT in the registry, supply it through an environment variable: + +```sh +export GDDY_PAT=gd_pat_... +gddy domain list --env prod +``` + +For per-environment tokens: + +```sh +export GDDY_PAT_PROD=gd_pat_... +export GDDY_PAT_OTE=gd_pat_... +gddy domain list --env prod +``` + +Per-environment variables take precedence over `GDDY_PAT`. + +### How the CLI chooses a credential + +For each command, the CLI resolves credentials in this order: + +1. `GDDY_PAT_` environment variable +2. `GDDY_PAT` environment variable +3. Stored PAT for the environment in the `gddy` configuration directory (e.g. `~/.config/gddy/pat.toml` on Linux) +4. OAuth token from `gddy auth login` (browser flow) + +If a PAT is configured, the CLI sends it as: + +```text +Authorization: Bearer gd_pat_... +``` + +The GoDaddy API gateway exchanges the PAT for a short-lived access token and enforces the PAT's scopes. If a command needs scopes the PAT does not grant, the API returns `403` and the CLI surfaces that error. + +## Security notes + +- Treat PATs like passwords. Do not commit them to source control. +- Prefer `GDDY_PAT_` environment variables in CI over storing PATs in the registry file. +- Regenerate leaked or suspected PATs in the Developer Portal immediately. +- The CLI validates PAT format before storing, but does **not** contact the API to verify a PAT is still active. diff --git a/rust/src/pat/mod.rs b/rust/src/pat/mod.rs new file mode 100644 index 0000000..806a052 --- /dev/null +++ b/rust/src/pat/mod.rs @@ -0,0 +1,654 @@ +//! `gddy pat` — manage Personal Access Tokens (PATs) for non-interactive auth. +//! +//! PATs complement the interactive OAuth PKCE flow managed by `gddy auth`. They +//! are long-lived OAuth refresh tokens created in the GoDaddy Developer Portal +//! and used as `Authorization: Bearer gd_pat_…`. The gateway exchanges the PAT +//! for a short-lived access token, so the CLI only needs to store and send the +//! PAT itself. +//! +//! PATs are persisted in the `gddy` configuration directory alongside other CLI +//! configuration files (e.g. `~/.config/gddy/pat.toml` on Linux). They are written +//! with owner-only file permissions where the platform supports it. They can also +//! be supplied at runtime via the `GDDY_PAT` or `GDDY_PAT_` environment +//! variables, which take precedence over the registry file. + +use std::collections::BTreeMap; + +use cli_engine::{ + CliCoreError, CommandResult, CommandSpec, GroupSpec, Module, NextAction, RuntimeCommandSpec, + RuntimeGroupSpec, Tier, +}; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::environments; +use crate::output_schema::output_schema; + +output_schema!(PatListItem { + "env": "string"; + "name": "string"; + "lastFour": "string"; +}); + +output_schema!(PatAddResult { + "env": "string"; + "name": "string"; + "lastFour": "string"; + "path": "string"; +}); + +output_schema!(PatRemoveResult { + "env": "string"; + "status": "string"; +}); + +/// PAT prefix advertised by GoDaddy. A valid PAT starts with this string. +pub const PAT_PREFIX: &str = "gd_pat_"; + +/// The base portion of the advertised prefix, used when validating the +/// `gd_pat__` structure. +const PAT_PREFIX_BODY: &str = "gd_pat"; + +/// Default env-var PAT applied to any environment. +pub const PAT_ENV_VAR: &str = "GDDY_PAT"; + +/// Provider name reported in `Credential.provider` when a PAT is used. +pub const PROVIDER: &str = "pat"; + +const PAT_FILE_NAME: &str = "pat.toml"; + +/// One stored PAT. +#[derive(Clone, Serialize, Deserialize)] +pub struct PatEntry { + /// Plaintext PAT. Never displayed in full after initial entry. + pub token: String, + /// Human-readable label chosen when the PAT was added. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub name: String, +} + +impl std::fmt::Debug for PatEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PatEntry") + .field("token", &redact(&self.token)) + .field("name", &self.name) + .finish() + } +} + +/// Registry of PATs keyed by environment name. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct PatRegistry { + #[serde(default)] + tokens: BTreeMap, +} + +impl PatRegistry { + /// Load the PAT registry from disk, or an empty registry if the file is missing. + /// A malformed file is treated as an error so a user notices and can fix it. + fn load(path: &std::path::Path) -> Result { + match std::fs::read_to_string(path) { + Ok(contents) => toml::from_str(&contents) + .map_err(|e| CliCoreError::message(format!("{}: {e}", path.display()))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()), + Err(e) => Err(CliCoreError::message(format!("{}: {e}", path.display()))), + } + } + + /// Save the registry to disk atomically with owner-only permissions. + fn save(&self, path: &std::path::Path) -> Result<(), CliCoreError> { + let contents = toml::to_string_pretty(self) + .map_err(|e| CliCoreError::message(format!("failed to serialize PAT registry: {e}")))?; + cli_engine::fs::write_string_atomic(path, &contents) + } +} + +/// Path to the local PAT registry file, if a config directory can be resolved. +/// Mirrors the other `gddy/` config paths in this crate. +pub fn registry_path() -> Option { + dirs::config_dir().map(|d| d.join("gddy").join(PAT_FILE_NAME)) +} + +fn env_var_for(env: &str) -> String { + format!("{}_{}", PAT_ENV_VAR, environments::env_prefix(env)) +} + +fn load_registry(path: &std::path::Path) -> Result { + PatRegistry::load(path) +} + +fn save_registry(path: &std::path::Path, registry: &PatRegistry) -> Result<(), CliCoreError> { + registry.save(path) +} + +/// Validate that `token` looks like a GoDaddy PAT: `gd_pat__<8 hex crc>`. +#[must_use] +pub fn is_valid_pat(token: &str) -> bool { + let Some((body, crc)) = token.rsplit_once('_') else { + return false; + }; + if crc.len() != 8 || !crc.chars().all(|c| c.is_ascii_hexdigit()) { + return false; + } + let Some((prefix, entropy)) = body.rsplit_once('_') else { + return false; + }; + prefix == PAT_PREFIX_BODY + && !entropy.is_empty() + && entropy.chars().all(|c| c.is_ascii_alphanumeric()) +} + +/// Returns the last four characters of a PAT. If the token is four characters or +/// shorter, returns the whole token unchanged. +fn last_four(token: &str) -> String { + token + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect() +} + +fn redact(token: &str) -> String { + let len = token.chars().count(); + if len <= 8 { + return "*".repeat(len); + } + let prefix: String = token.chars().take(4).collect(); + let suffix: String = token + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("{prefix}****{suffix}") +} + +/// Find a PAT for `env`, preferring per-env env vars, then the default env var, +/// then the registry file. Returns `None` when no valid PAT is configured. +pub async fn resolve_pat(env: &str) -> Option { + // Check env vars before touching the registry. This keeps the registry-load + // warning honest (env vars have already been ruled out) and avoids I/O when + // a PAT is supplied via the environment. + if let Some(entry) = resolve_pat_with(env, |var| std::env::var(var).ok(), None) { + return Some(entry); + } + let path = registry_path()?; + let registry = match load_registry(&path) { + Ok(r) => r, + Err(err) => { + tracing::warn!( + env, + error = %err, + "failed to load PAT registry; falling back to OAuth" + ); + return None; + } + }; + resolve_pat_with(env, |_| None, Some(®istry)) +} + +/// Internal, testable version of [`resolve_pat`]. `get_env` supplies env-var values; +/// `registry` is the pre-loaded PAT registry (if any). Every token is validated +/// before it is returned. +fn resolve_pat_with( + env: &str, + mut get_env: F, + registry: Option<&PatRegistry>, +) -> Option +where + F: FnMut(&str) -> Option, +{ + if let Some(token) = get_env(&env_var_for(env)) { + if is_valid_pat(&token) { + return Some(PatEntry { + token, + name: "env".to_owned(), + }); + } + tracing::warn!( + env, + var = env_var_for(env), + "PAT env var is malformed; ignoring" + ); + } + if let Some(token) = get_env(PAT_ENV_VAR) { + if is_valid_pat(&token) { + return Some(PatEntry { + token, + name: "env".to_owned(), + }); + } + tracing::warn!(var = PAT_ENV_VAR, "PAT env var is malformed; ignoring"); + } + let entry = registry?.tokens.get(env).cloned()?; + if !is_valid_pat(&entry.token) { + tracing::warn!(env, "stored PAT entry is malformed; ignoring"); + return None; + } + Some(entry) +} + +/// Persist a PAT for an environment, replacing any existing entry. +pub async fn save_pat(env: &str, entry: PatEntry) -> Result<(), CliCoreError> { + let path = registry_path().ok_or_else(|| { + CliCoreError::message("could not determine a config directory for the PAT registry") + })?; + let mut registry = load_registry(&path)?; + registry.tokens.insert(env.to_owned(), entry); + save_registry(&path, ®istry) +} + +/// Remove a stored PAT for an environment. Returns `true` if one existed. +pub async fn delete_pat(env: &str) -> Result { + let Some(path) = registry_path() else { + return Ok(false); + }; + let mut registry = load_registry(&path)?; + let existed = registry.tokens.remove(env).is_some(); + if existed { + save_registry(&path, ®istry)?; + } + Ok(existed) +} + +/// List environments that have a PAT in the registry. +pub async fn list_pats() -> Result, CliCoreError> { + let Some(path) = registry_path() else { + return Ok(Vec::new()); + }; + let registry = load_registry(&path)?; + Ok(registry.tokens.into_iter().collect()) +} + +/// Return the environment names that have a stored PAT in the registry. +pub async fn registry_envs() -> Result, CliCoreError> { + let Some(path) = registry_path() else { + return Ok(Vec::new()); + }; + let registry = load_registry(&path)?; + Ok(registry.tokens.into_keys().collect()) +} + +fn registry_path_err() -> Result { + registry_path().ok_or_else(|| { + CliCoreError::message("could not determine a config directory for the PAT registry") + }) +} + +pub fn module() -> Module { + Module::new("Authentication", |_ctx| { + RuntimeGroupSpec::new( + GroupSpec::new("pat", "Manage Personal Access Tokens (PATs)").with_long( + "Store and manage Personal Access Tokens for non-interactive GoDaddy \ + authentication. PATs are created in the Developer Portal and are useful \ + for CI/CD pipelines and scripts where browser-based OAuth is not \ + possible.\n\ + \n\ + • add — store a PAT for an environment (reads from stdin)\n\ + • list — show stored PATs (last-four only)\n\ + • remove — delete the PAT for an environment\n\ + \n\ + PATs can also be supplied with the GDDY_PAT or GDDY_PAT_ \ + environment variables. See `gddy guide auth`.", + ), + ) + .with_command(add_command()) + .with_command(list_command()) + .with_command(remove_command()) + }) + .with_guides_from_markdown([("auth.md", include_bytes!("guides/auth.md").as_slice())]) +} + +fn add_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("add", "Store a PAT for an environment") + .with_long( + "Stores a Personal Access Token for the target environment.\n\ + The token is read from stdin by default so it does not appear in shell \ + history. Alternatively, pass --token.\n\ + \n\ + Example:\n \ + echo 'gd_pat_...' | gddy pat add --env prod \"CI token\"", + ) + .with_system("pat") + .with_tier(Tier::Mutate) + .mutates(true) + .no_auth(true) + .with_output_schema::() + .with_default_fields("env,name,lastFour,path") + .with_arg( + clap::Arg::new("env") + .long("env") + .value_name("ENV") + .required(true) + .help("Environment the PAT is for (e.g. prod or ote)"), + ) + .with_arg( + clap::Arg::new("token") + .long("token") + .value_name("TOKEN") + .help("PAT value (if omitted, read from stdin)"), + ) + .with_arg( + clap::Arg::new("name") + .value_name("NAME") + .required(true) + .help("Human-readable label for this PAT"), + ), + |ctx| async move { + let env = string_arg(&ctx.args, "env"); + // Validate the environment up front so a typo does not stash a PAT + // for a non-existent env. + environments::resolve(&env).map_err(|e| CliCoreError::message(e.to_string()))?; + + let name = string_arg(&ctx.args, "name"); + let token = resolve_token_arg(&ctx.args).await?; + if !is_valid_pat(&token) { + return Err(CliCoreError::message(format!( + "token does not look like a GoDaddy PAT (expected `{PAT_PREFIX}...`); refusing to store it" + ))); + } + + let entry = PatEntry { token, name }; + save_pat(&env, entry.clone()).await?; + + let path = registry_path_err()?; + Ok(CommandResult::new(json!({ + "env": env, + "name": entry.name, + "lastFour": last_four(&entry.token), + "path": path.display().to_string(), + })) + .with_next_actions(vec![NextAction::new("guide auth", "Learn about PAT auth")])) + }, + ) +} + +fn list_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new( + CommandSpec::new("list", "List stored PATs") + .with_long( + "Lists every PAT stored in the registry, showing only the last four \ + characters of each token. Use `GDDY_PAT` or `GDDY_PAT_` env vars to \ + provide a PAT without storing it in the registry.", + ) + .with_system("pat") + .with_tier(Tier::Read) + .no_auth(true) + .with_output_schema::() + .with_default_fields("env,name,lastFour"), + |_cred, _args| async move { + let mut entries: Vec<_> = list_pats() + .await? + .into_iter() + .map(|(env, entry)| { + json!({ + "env": env, + "name": entry.name, + "lastFour": last_four(&entry.token), + }) + }) + .collect(); + entries.sort_by(|a, b| a["env"].as_str().cmp(&b["env"].as_str())); + Ok(CommandResult::new(json!(entries))) + }, + ) +} + +fn remove_command() -> RuntimeCommandSpec { + RuntimeCommandSpec::new_with_context( + CommandSpec::new("remove", "Remove the PAT for an environment") + .with_long( + "Deletes the stored PAT for the given environment. This does not revoke \ + the PAT in the Developer Portal; it only removes the local copy.", + ) + .with_system("pat") + .with_tier(Tier::Mutate) + .mutates(true) + .no_auth(true) + .with_output_schema::() + .with_default_fields("env,status") + .with_arg( + clap::Arg::new("env") + .long("env") + .value_name("ENV") + .required(true) + .help("Environment whose PAT should be removed"), + ), + |ctx| async move { + let env = string_arg(&ctx.args, "env"); + // Validate the environment up front so a typo produces a clear error + // instead of silently reporting "not found". + environments::resolve(&env).map_err(|e| CliCoreError::message(e.to_string()))?; + let existed = delete_pat(&env).await?; + Ok(CommandResult::new(json!({ + "env": env, + "status": if existed { "removed" } else { "not found" }, + }))) + }, + ) +} + +fn string_arg(args: &serde_json::Map, name: &str) -> String { + args.get(name) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_owned() +} + +async fn read_stdin_token() -> Result { + use tokio::io::AsyncBufReadExt as _; + let stdin = tokio::io::stdin(); + let mut reader = tokio::io::BufReader::new(stdin); + let mut line = String::new(); + let bytes_read = reader + .read_line(&mut line) + .await + .map_err(|e| CliCoreError::message(format!("failed to read PAT from stdin: {e}")))?; + parse_stdin_token(&line, bytes_read) +} + +/// Validate that a line read from stdin actually contains a token. +/// Returns an actionable error on EOF or whitespace-only input. +fn parse_stdin_token(line: &str, bytes_read: usize) -> Result { + if bytes_read == 0 || line.trim().is_empty() { + return Err(CliCoreError::message( + "no PAT provided on stdin; pass --token or pipe the PAT", + )); + } + Ok(line.trim().to_owned()) +} + +/// Return the PAT supplied on the command line, trimmed of surrounding whitespace, +/// or read it from stdin when `--token` is absent or whitespace-only. +async fn resolve_token_arg( + args: &serde_json::Map, +) -> Result { + if let Some(t) = args.get("token").and_then(|v| v.as_str()) { + let trimmed = t.trim(); + if !trimmed.is_empty() { + return Ok(trimmed.to_owned()); + } + } + read_stdin_token().await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_malformed_pats() { + assert!(!is_valid_pat("")); + assert!(!is_valid_pat("notapat")); + assert!(!is_valid_pat("gd_pat_")); + assert!(!is_valid_pat("gd_pat_abc")); // missing crc + assert!(!is_valid_pat("gd_pat_abc_xyz")); // crc too short + assert!(!is_valid_pat("gd_pat_abc_zzzzzzzz")); // crc not hex + assert!(!is_valid_pat("gd_pat_ab!c_12345678")); // invalid char in entropy + } + + #[test] + fn accepts_well_formed_pat() { + assert!(is_valid_pat("gd_pat_abc123_1234abcd")); + assert!(is_valid_pat("gd_pat_aA0bB1cC2_abcdef12")); + // entropy may be long + assert!(is_valid_pat( + "gd_pat_abcdefghijklmnopqrstuvwxyz0123456789_abcdef12" + )); + } + + #[test] + fn last_four_extracts_suffix() { + assert_eq!(last_four("gd_pat_abc123_1234abcd"), "abcd"); + assert_eq!(last_four("short"), "hort"); + assert_eq!(last_four("ab"), "ab"); + } + + #[test] + fn redact_masks_prefix_and_suffix_and_handles_unicode() { + assert_eq!(redact("gd_pat_abc123_1234abcd"), "gd_p****abcd"); + assert_eq!(redact("short"), "*****"); + // Multi-byte characters must not cause panics at byte boundaries. + let unicode = "éééé_éééé_éééé"; + assert!(!redact(unicode).is_empty()); + } + + #[tokio::test] + async fn cli_token_is_trimmed_before_validation() { + let mut args = serde_json::Map::new(); + args.insert( + "token".to_owned(), + serde_json::Value::String(" gd_pat_a_12345678 \n".to_owned()), + ); + let token = resolve_token_arg(&args).await.unwrap(); + assert_eq!(token, "gd_pat_a_12345678"); + assert!(is_valid_pat(&token)); + } + + #[test] + fn empty_stdin_token_returns_actionable_error() { + assert!(parse_stdin_token("", 0).is_err()); + assert!(parse_stdin_token(" \n", 3).is_err()); + assert_eq!( + parse_stdin_token(" gd_pat_a_12345678 \n", 23).unwrap(), + "gd_pat_a_12345678" + ); + } + + #[test] + fn registry_round_trip() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("pat.toml"); + + let mut registry = PatRegistry::default(); + registry.tokens.insert( + "prod".to_owned(), + PatEntry { + token: "gd_pat_abc123_1234abcd".to_owned(), + name: "CI".to_owned(), + }, + ); + registry.save(&path).expect("save"); + + let loaded = PatRegistry::load(&path).expect("load"); + assert_eq!(loaded.tokens.len(), 1); + assert_eq!(loaded.tokens["prod"].name, "CI"); + assert_eq!(loaded.tokens["prod"].token, "gd_pat_abc123_1234abcd"); + } + + fn test_registry_with(env: &str, token: &str) -> PatRegistry { + let mut registry = PatRegistry::default(); + registry.tokens.insert( + env.to_owned(), + PatEntry { + token: token.to_owned(), + name: "stored".to_owned(), + }, + ); + registry + } + + #[test] + fn per_env_var_wins_over_default_and_registry() { + let registry = test_registry_with("prod", "gd_pat_registry_12345678"); + let env = |var: &str| match var { + "GDDY_PAT_PROD" => Some("gd_pat_prod_12345678".to_owned()), + "GDDY_PAT" => Some("gd_pat_default_12345678".to_owned()), + _ => None, + }; + let entry = resolve_pat_with("prod", env, Some(®istry)).unwrap(); + assert_eq!(entry.token, "gd_pat_prod_12345678"); + assert_eq!(entry.name, "env"); + } + + #[test] + fn default_env_var_wins_over_registry() { + let registry = test_registry_with("prod", "gd_pat_registry_12345678"); + let env = |var: &str| match var { + "GDDY_PAT" => Some("gd_pat_default_12345678".to_owned()), + _ => None, + }; + let entry = resolve_pat_with("prod", env, Some(®istry)).unwrap(); + assert_eq!(entry.token, "gd_pat_default_12345678"); + assert_eq!(entry.name, "env"); + } + + #[test] + fn malformed_env_var_is_ignored_and_fallback_works() { + let registry = test_registry_with("prod", "gd_pat_registry_12345678"); + let env = |var: &str| match var { + "GDDY_PAT_PROD" => Some("not-a-pat".to_owned()), + "GDDY_PAT" => Some("gd_pat_default_12345678".to_owned()), + _ => None, + }; + let entry = resolve_pat_with("prod", env, Some(®istry)).unwrap(); + assert_eq!(entry.token, "gd_pat_default_12345678"); + } + + #[test] + fn registry_entry_returned_when_no_env_vars() { + let registry = test_registry_with("prod", "gd_pat_registry_12345678"); + let env = |_var: &str| None; + let entry = resolve_pat_with("prod", env, Some(®istry)).unwrap(); + assert_eq!(entry.token, "gd_pat_registry_12345678"); + assert_eq!(entry.name, "stored"); + } + + #[test] + fn stored_malformed_pat_is_ignored() { + let registry = test_registry_with("prod", "not-a-pat"); + let env = |_var: &str| None; + assert!(resolve_pat_with("prod", env, Some(®istry)).is_none()); + } + + #[test] + fn redact_uses_char_boundaries_and_does_not_panic() { + // Regression test: byte-index slicing would panic on this token. + let token = "gd_pat_αβγδ_12345678"; + let redacted = redact(token); + assert!(redacted.starts_with("gd_p")); + assert!(redacted.ends_with("5678")); + assert!(redacted.contains("****")); + } + + #[test] + fn debug_format_does_not_panic_on_multibyte_token() { + let entry = PatEntry { + token: "gd_pat_αβγδ_12345678".to_owned(), + name: "test".to_owned(), + }; + let _ = format!("{:?}", entry); + } + + #[test] + fn env_var_for_uses_uppercase_env_prefix() { + assert_eq!(env_var_for("prod"), "GDDY_PAT_PROD"); + assert_eq!(env_var_for("ote"), "GDDY_PAT_OTE"); + } +}