diff --git a/README.md b/README.md index 5d8d4c2..b96ec1d 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ RW_VERSION=1.2.3 bash -c "$(curl -fsSL https://raw.githubusercontent.com/Roundin | Flag | Short | Description | |----------------|-------|------------------------------------------------------------------------| | `--profile` | `-p` | Named profile to use | -| `--auth` | `-A` | Use stored credentials from another profile (overrides only the auth) | +| `--auth` | `-A` | Use stored credentials from another profile (overrides only the auth) | +| `--stage` | `-g` | Stage to target, overriding the profile's configured stage | | `--config-dir` | `-c` | Configuration directory | | `--json` | | Change all output to JSON | @@ -48,6 +49,31 @@ rw config profile set mercy -g sandbox # Update stage for a profile rw config profile auth mercy # Save basic auth credentials for a profile (see below) ``` +#### Overriding the stage + +A profile pins an organization to one stage. Override it for a single invocation +with `--stage` (`-g`): + +```sh +rw -g local workspaces list # Target localhost with the active profile +rw clinicians show me -g sandbox # The flag is global, so it works here too +``` + +The override changes only which host is called. Credentials still come from the +profile (or from `--auth`), and token refresh still uses the profile's stored +stage. Because `prod` and `sandbox` authenticate against a different tenant than +`qa`, `dev`, and `local`, an override that crosses that boundary sends a token the +target rejects, producing a 401 — overrides within a group work. + +The exception is `rw config profile add` and `rw config profile set`, which +each have their own local `-g` / `--stage` flag for the stage to store in the +profile. Because it shares the arg id with the global flag, `-g`/`--stage` +anywhere on those two commands sets the profile's stored stage — it does not +act as a per-invocation override there. + +`--stage` cannot be combined with `rw auth login` or `rw auth logout`, which +create and remove a specific profile's credentials. + #### Adding a profile ```sh diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..df46791 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1 @@ +superpowers/ diff --git a/docs/config.md b/docs/config.md index 05ed9ac..156994a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,6 +29,13 @@ By default, `rw` stores these files under `~/.config/rw/`. } ``` +A profile's `stage` is the default for every invocation. Pass `--stage` (`-g`) to +target a different stage for one command without editing the profile; credentials +are still read from `auth/{profile}.json` either way. The exception is `rw config +profile add` and `rw config profile set`, whose own local `-g` / `--stage` flag +shares the same arg id — there, `-g`/`--stage` sets and persists the profile's +stored stage instead of overriding it for one command. + ### `auth/{profile}.json` Bearer token (written after `rw auth login`): diff --git a/skills/rw-skill.md b/skills/rw-skill.md index 5f53441..463aaeb 100644 --- a/skills/rw-skill.md +++ b/skills/rw-skill.md @@ -26,10 +26,11 @@ These flags work on every command: |----------------|-------|------------------------------------------------------------------------| | `--profile` | `-p` | Named profile to use | | `--auth` | `-A` | Use credentials from another profile (overrides only the auth source) | +| `--stage` | `-g` | Stage to target, overriding the profile's configured stage | | `--config-dir` | `-c` | Configuration directory path | | `--json` | | Output results as JSON | -All commands that call the API require a configured profile. Ensure that a profile has been set using `rw config profile show` or pass `--profile` on each invocation. +All commands that call the API require a configured profile. Ensure that a profile has been set using `rw config profile show` or pass `--profile` on each invocation. Pass `--stage` to target a different stage than the profile's configured one; it cannot be combined with `rw auth login` or `rw auth logout`. It also does not act as an override on `rw config profile add` or `rw config profile set` — those commands have their own local `-g`/`--stage` flag sharing the same arg id, so `-g`/`--stage` anywhere on those commands instead sets and persists the profile's stored stage. Do not use `--stage` expecting a one-off override on those two commands; it will silently mutate stored config. ## Commands diff --git a/src/cli.rs b/src/cli.rs index ff1af0f..de839e8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -16,6 +16,10 @@ pub struct Cli { #[arg(short = 'A', long, value_parser = validate_slug, global = true)] pub auth: Option, + /// Stage to target for this invocation, overriding the profile's configured stage. + #[arg(short = 'g', long, global = true)] + pub stage: Option, + /// Output results as JSON. #[arg(long, global = true)] pub json: bool, @@ -545,4 +549,76 @@ mod tests { let cli = Cli::try_parse_from(["rw", "auth", "status", "-A", "mercy"]).unwrap(); assert_eq!(cli.auth.as_deref(), Some("mercy")); } + + #[test] + fn test_stage_flag_long() { + use clap::Parser; + let cli = Cli::try_parse_from(["rw", "--stage", "dev", "auth", "status"]).unwrap(); + assert_eq!(cli.stage, Some(Stage::Dev)); + } + + #[test] + fn test_stage_flag_short() { + use clap::Parser; + let cli = Cli::try_parse_from(["rw", "-g", "local", "auth", "status"]).unwrap(); + assert_eq!(cli.stage, Some(Stage::Local)); + } + + #[test] + fn test_stage_flag_default_none() { + use clap::Parser; + let cli = Cli::try_parse_from(["rw", "auth", "status"]).unwrap(); + assert!(cli.stage.is_none()); + } + + #[test] + fn test_stage_flag_rejects_unknown_stage() { + use clap::Parser; + let err = Cli::try_parse_from(["rw", "-g", "staging", "auth", "status"]).unwrap_err(); + assert!(err.to_string().contains("invalid value")); + } + + #[test] + fn test_stage_flag_propagates_to_subcommands() { + use clap::Parser; + // The flag is global, so it works after the subcommand too. + let cli = Cli::try_parse_from(["rw", "clinicians", "show", "me", "-g", "dev"]).unwrap(); + assert_eq!(cli.stage, Some(Stage::Dev)); + } + + #[test] + fn test_config_profile_add_keeps_its_own_stage_flag() { + use clap::Parser; + // clap skips propagating a global arg into a subcommand that already + // declares the same arg id, so `add` keeps its local `-g`. + let cli = + Cli::try_parse_from(["rw", "config", "profile", "add", "demo", "-g", "prod"]).unwrap(); + let Commands::Config(config_args) = cli.command else { + panic!("expected the config subcommand"); + }; + let ConfigCommands::Profile(profile_args) = config_args.command else { + panic!("expected the profile subcommand"); + }; + let ConfigProfileCommands::Add(add_args) = profile_args.command else { + panic!("expected the add subcommand"); + }; + assert_eq!(add_args.stage, Some(Stage::Prod)); + } + + #[test] + fn test_config_profile_set_keeps_its_own_stage_flag() { + use clap::Parser; + let cli = Cli::try_parse_from(["rw", "config", "profile", "set", "demo", "-g", "sandbox"]) + .unwrap(); + let Commands::Config(config_args) = cli.command else { + panic!("expected the config subcommand"); + }; + let ConfigCommands::Profile(profile_args) = config_args.command else { + panic!("expected the profile subcommand"); + }; + let ConfigProfileCommands::Set(set_args) = profile_args.command else { + panic!("expected the set subcommand"); + }; + assert_eq!(set_args.stage, Some(Stage::Sandbox)); + } } diff --git a/src/commands/config/doctor.rs b/src/commands/config/doctor.rs index 9bd199a..22b3f83 100644 --- a/src/commands/config/doctor.rs +++ b/src/commands/config/doctor.rs @@ -15,6 +15,7 @@ use std::time::Instant; use crate::api::resolve_api; use crate::auth_cache::{load_auth_cache, AuthCache}; +use crate::cli::Stage; use crate::config::Config; use crate::output::{CommandOutput, Output}; @@ -70,9 +71,10 @@ pub async fn doctor( config: &Config, config_dir: &Path, profile_override: Option<&str>, + stage_override: Option<&Stage>, out: &Output, ) -> Result<()> { - let report = run_checks(config, config_dir, profile_override).await; + let report = run_checks(config, config_dir, profile_override, stage_override).await; out.print(&report); if !report.ok { return Err(anyhow!("doctor checks failed")); @@ -86,14 +88,15 @@ pub(crate) async fn run_checks( config: &Config, config_dir: &Path, profile_override: Option<&str>, + stage_override: Option<&Stage>, ) -> DoctorOutput { let mut checks: Vec = Vec::with_capacity(4); // 1. Profile. - let profile = check_profile(config, profile_override); + let profile = check_profile(config, profile_override, stage_override); let profile_ok = profile.status == CheckStatus::Pass; let profile_ctx = if profile_ok { - resolve_profile_ctx(config, profile_override) + resolve_profile_ctx(config, profile_override, stage_override) } else { None }; @@ -138,9 +141,13 @@ struct ProfileCtx { base_url: String, } -fn resolve_profile_ctx(config: &Config, profile_override: Option<&str>) -> Option { +fn resolve_profile_ctx( + config: &Config, + profile_override: Option<&str>, + stage_override: Option<&Stage>, +) -> Option { let (profile, organization, stage) = - crate::config::resolve_profile(config, profile_override).ok()?; + crate::config::resolve_profile(config, profile_override, stage_override).ok()?; let base_url = resolve_api(&organization, &stage); Some(ProfileCtx { profile, @@ -158,8 +165,12 @@ fn skip(name: &str, reason: &str) -> CheckResult { } } -fn check_profile(config: &Config, profile_override: Option<&str>) -> CheckResult { - match crate::config::resolve_profile(config, profile_override) { +fn check_profile( + config: &Config, + profile_override: Option<&str>, + stage_override: Option<&Stage>, +) -> CheckResult { + match crate::config::resolve_profile(config, profile_override, stage_override) { Ok((name, organization, stage)) => { let mut details = BTreeMap::new(); details.insert("profile".to_string(), serde_json::json!(name)); @@ -366,7 +377,6 @@ fn remaining(expires_at: i64) -> String { #[cfg(test)] mod tests { use super::*; - use crate::cli::Stage; use crate::config::Profile; use mockito::Server; @@ -396,7 +406,7 @@ mod tests { #[test] fn test_check_profile_pass() { let config = cfg_with_default(Stage::Prod); - let r = check_profile(&config, None); + let r = check_profile(&config, None, None); assert_eq!(r.status, CheckStatus::Pass); assert!(r.message.contains("demo")); assert!(r.message.contains("demonstration")); @@ -406,7 +416,7 @@ mod tests { #[test] fn test_check_profile_fails_without_default() { let config = Config::default(); - let r = check_profile(&config, None); + let r = check_profile(&config, None, None); assert_eq!(r.status, CheckStatus::Fail); assert!(r.message.contains("no profile selected")); } @@ -414,7 +424,7 @@ mod tests { #[test] fn test_check_profile_fails_for_unknown_override() { let config = cfg_with_default(Stage::Prod); - let r = check_profile(&config, Some("nope")); + let r = check_profile(&config, Some("nope"), None); assert_eq!(r.status, CheckStatus::Fail); assert!(r.message.contains("nope")); } @@ -631,7 +641,7 @@ mod tests { async fn test_run_checks_no_profile_cascades_skips() { let dir = tempfile::TempDir::new().unwrap(); let config = Config::default(); - let report = run_checks(&config, dir.path(), None).await; + let report = run_checks(&config, dir.path(), None, None).await; assert!(!report.ok); assert_eq!(report.checks.len(), 4); assert_eq!(report.checks[0].status, CheckStatus::Fail); @@ -644,7 +654,7 @@ mod tests { async fn test_run_checks_no_auth_skips_api_but_runs_defaults() { let dir = tempfile::TempDir::new().unwrap(); let config = cfg_with_default(Stage::Prod); - let report = run_checks(&config, dir.path(), None).await; + let report = run_checks(&config, dir.path(), None, None).await; assert!(!report.ok); assert_eq!(report.checks[0].status, CheckStatus::Pass); assert_eq!(report.checks[1].status, CheckStatus::Fail); @@ -657,7 +667,7 @@ mod tests { async fn test_doctor_output_plain_format() { let dir = tempfile::TempDir::new().unwrap(); let config = Config::default(); - let report = run_checks(&config, dir.path(), None).await; + let report = run_checks(&config, dir.path(), None, None).await; let plain = report.plain(); // Each check renders one line with a glyph + name. assert_eq!(plain.lines().count(), 4); @@ -671,7 +681,7 @@ mod tests { async fn test_doctor_output_json_shape() { let dir = tempfile::TempDir::new().unwrap(); let config = Config::default(); - let report = run_checks(&config, dir.path(), None).await; + let report = run_checks(&config, dir.path(), None, None).await; let json = serde_json::to_value(&report).unwrap(); assert_eq!(json["ok"], false); assert!(json["checks"].is_array()); @@ -684,7 +694,49 @@ mod tests { let dir = tempfile::TempDir::new().unwrap(); let config = Config::default(); let out = Output { json: true }; - let result = doctor(&config, dir.path(), None, &out).await; + let result = doctor(&config, dir.path(), None, None, &out).await; assert!(result.is_err()); } + + #[test] + fn test_check_profile_reports_stage_override() { + let config = cfg_with_default(Stage::Prod); + let r = check_profile(&config, None, Some(&Stage::Local)); + assert_eq!(r.status, CheckStatus::Pass); + assert!(r.message.contains("local")); + assert_eq!(r.details["stage"], serde_json::json!("local")); + } + + #[test] + fn test_check_profile_without_override_reports_configured_stage() { + let config = cfg_with_default(Stage::Prod); + let r = check_profile(&config, None, None); + assert_eq!(r.status, CheckStatus::Pass); + assert_eq!(r.details["stage"], serde_json::json!("prod")); + } + + #[test] + fn test_resolve_profile_ctx_honors_stage_override() { + let config = cfg_with_default(Stage::Prod); + let ctx = resolve_profile_ctx(&config, None, Some(&Stage::Local)).unwrap(); + assert_eq!(ctx.profile, "demo"); + assert_eq!(ctx.base_url, "http://localhost:8080"); + } + + #[test] + fn test_resolve_profile_ctx_without_override_uses_configured_stage() { + let config = cfg_with_default(Stage::Prod); + let ctx = resolve_profile_ctx(&config, None, None).unwrap(); + assert_eq!(ctx.base_url, "https://demonstration.roundingwell.com/api"); + } + + #[tokio::test] + async fn test_run_checks_profile_check_reflects_stage_override() { + let dir = tempfile::TempDir::new().unwrap(); + let config = cfg_with_default(Stage::Prod); + let report = run_checks(&config, dir.path(), None, Some(&Stage::Local)).await; + let profile = report.checks.iter().find(|c| c.name == "profile").unwrap(); + assert_eq!(profile.status, CheckStatus::Pass); + assert_eq!(profile.details["stage"], serde_json::json!("local")); + } } diff --git a/src/commands/config/mod.rs b/src/commands/config/mod.rs index ffb66ee..29b340e 100644 --- a/src/commands/config/mod.rs +++ b/src/commands/config/mod.rs @@ -18,7 +18,8 @@ use anyhow::Result; use std::path::Path; use crate::cli::{ - ConfigArgs, ConfigCommands, ConfigDefaultCommands, ConfigProfileCommands, ConfigUpdatesCommands, + ConfigArgs, ConfigCommands, ConfigDefaultCommands, ConfigProfileCommands, + ConfigUpdatesCommands, Stage, }; use crate::config::Config; use crate::output::Output; @@ -29,10 +30,13 @@ pub async fn dispatch( cfg_path: &Path, config_dir: &Path, profile_override: Option<&str>, + stage_override: Option<&Stage>, out: &Output, ) -> Result<()> { match args.command { - ConfigCommands::Doctor => doctor::doctor(config, config_dir, profile_override, out).await, + ConfigCommands::Doctor => { + doctor::doctor(config, config_dir, profile_override, stage_override, out).await + } ConfigCommands::Profile(profile_args) => match profile_args.command { ConfigProfileCommands::List => { profile_list(config, out); diff --git a/src/config.rs b/src/config.rs index 9d606cf..10d820e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -104,15 +104,22 @@ pub fn save_config_to(config: &Config, path: &std::path::Path) -> Result<()> { /// Resolves the effective profile name, organization, and stage. /// Uses `--profile` if given, then `default` from config. -/// Returns an error if neither is set. -pub fn resolve_profile(config: &Config, profile: Option<&str>) -> Result<(String, String, Stage)> { +/// When `stage_override` is set (from `--stage`), it replaces the profile's +/// configured stage; profile lookup and its errors happen first either way. +/// Returns an error if neither `--profile` nor `default` is set. +pub fn resolve_profile( + config: &Config, + profile: Option<&str>, + stage_override: Option<&Stage>, +) -> Result<(String, String, Stage)> { let effective_profile = profile.or(config.default.as_deref()); if let Some(name) = effective_profile { let p = config .profiles .get(name) .with_context(|| format!("profile \"{}\" not found in config", name))?; - Ok((name.to_string(), p.organization.clone(), p.stage.clone())) + let stage = stage_override.cloned().unwrap_or_else(|| p.stage.clone()); + Ok((name.to_string(), p.organization.clone(), stage)) } else { anyhow::bail!( "no profile selected; run `rw config profile use ` to set a default, or pass --profile" @@ -166,7 +173,7 @@ mod tests { default: None, }, ); - let (profile, organization, stage) = resolve_profile(&config, Some("demo")).unwrap(); + let (profile, organization, stage) = resolve_profile(&config, Some("demo"), None).unwrap(); assert_eq!(profile, "demo"); assert_eq!(organization, "demonstration"); assert_eq!(stage, Stage::Prod); @@ -184,7 +191,7 @@ mod tests { }, ); config.default = Some("demo".to_string()); - let (profile, organization, stage) = resolve_profile(&config, None).unwrap(); + let (profile, organization, stage) = resolve_profile(&config, None, None).unwrap(); assert_eq!(profile, "demo"); assert_eq!(organization, "demonstration"); assert_eq!(stage, Stage::Sandbox); @@ -193,7 +200,7 @@ mod tests { #[test] fn test_resolve_no_profile_errors() { let config = Config::default(); - assert!(resolve_profile(&config, None).is_err()); + assert!(resolve_profile(&config, None, None).is_err()); } #[test] @@ -349,4 +356,36 @@ mod tests { let result = resolve_auth_profile(&config, "demo", Some("demo")).unwrap(); assert_eq!(result, "demo"); } + + #[test] + fn test_resolve_profile_stage_override_replaces_configured_stage() { + let mut config = Config::default(); + config.profiles.insert( + "demo".to_string(), + Profile { + organization: "demonstration".to_string(), + stage: Stage::Prod, + default: None, + }, + ); + let (profile, organization, stage) = + resolve_profile(&config, Some("demo"), Some(&Stage::Local)).unwrap(); + assert_eq!(profile, "demo"); + assert_eq!(organization, "demonstration"); + assert_eq!(stage, Stage::Local); + } + + #[test] + fn test_resolve_profile_stage_override_does_not_mask_unknown_profile() { + let config = Config::default(); + let err = resolve_profile(&config, Some("nope"), Some(&Stage::Local)).unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn test_resolve_profile_stage_override_does_not_mask_missing_profile() { + let config = Config::default(); + let err = resolve_profile(&config, None, Some(&Stage::Local)).unwrap_err(); + assert!(err.to_string().contains("no profile selected")); + } } diff --git a/src/main.rs b/src/main.rs index 54db8f1..fe06782 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,9 +25,10 @@ fn build_ctx( config: &config::Config, profile: Option<&str>, auth: Option<&str>, + stage_override: Option<&cli::Stage>, config_dir: PathBuf, ) -> Result { - let (profile, organization, stage) = resolve_profile(config, profile)?; + let (profile, organization, stage) = resolve_profile(config, profile, stage_override)?; let auth_profile = config::resolve_auth_profile(config, &profile, auth)?; let auth_stage = config .profiles @@ -73,6 +74,31 @@ fn check_auth_compatible(cmd: &Commands, auth: Option<&str>) -> Result<()> { Ok(()) } +/// Returns an error when `--stage` is set but the command is `auth login` or +/// `auth logout`. `login` mints its token from the overridden stage's WorkOS +/// tenant yet stores it under the profile, whose refreshes use the profile's +/// *configured* stage — across tenants that leaves a token the profile can never +/// refresh. Within a tenant the minted token is identical either way, so the +/// override buys nothing. `logout` merely deletes the profile's credential file, +/// so a stage override is meaningless there. +fn check_stage_compatible(cmd: &Commands, stage: Option<&cli::Stage>) -> Result<()> { + if stage.is_none() { + return Ok(()); + } + if let Commands::Auth(args) = cmd { + match args.command { + cli::AuthCommands::Login => { + anyhow::bail!("--stage cannot be used with `rw auth login`"); + } + cli::AuthCommands::Logout => { + anyhow::bail!("--stage cannot be used with `rw auth logout`"); + } + cli::AuthCommands::Status | cli::AuthCommands::Header => {} + } + } + Ok(()) +} + async fn run(cli: Cli, out: &Output) -> Result<()> { let config_dir: PathBuf = if let Some(ref dir) = cli.config_dir { let path = PathBuf::from(dir); @@ -110,9 +136,11 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { } check_auth_compatible(&cli.command, cli.auth.as_deref())?; + check_stage_compatible(&cli.command, cli.stage.as_ref())?; let profile_override = cli.profile.clone(); let auth_override = cli.auth.clone(); + let stage_override = cli.stage.clone(); match cli.command { Commands::Actions(args) => { @@ -120,6 +148,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::actions::dispatch(args, &ctx, out).await?; @@ -129,6 +158,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::artifacts::dispatch(args, &ctx, out).await?; @@ -138,6 +168,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::auth::dispatch(args, &ctx, out).await?; @@ -147,6 +178,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::clinicians::dispatch(args, &ctx, out).await?; @@ -156,6 +188,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::teams::dispatch(args, &ctx, out).await?; @@ -165,6 +198,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::roles::dispatch(args, &ctx, out).await?; @@ -174,6 +208,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::workspaces::dispatch(args, &ctx, out).await?; @@ -183,6 +218,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &config, profile_override.as_deref(), auth_override.as_deref(), + stage_override.as_ref(), config_dir, )?; commands::api::dispatch(args, &ctx, out).await?; @@ -195,6 +231,7 @@ async fn run(cli: Cli, out: &Output) -> Result<()> { &cfg_path, &config_dir, profile_override.as_deref(), + stage_override.as_ref(), out, ) .await? @@ -228,6 +265,12 @@ mod tests { }) } + fn header_cmd() -> Commands { + Commands::Auth(AuthArgs { + command: AuthCommands::Header, + }) + } + #[test] fn test_check_auth_compatible_allows_login_without_override() { assert!(check_auth_compatible(&login_cmd(), None).is_ok()); @@ -273,7 +316,7 @@ mod tests { }, ); - let ctx = build_ctx(&config, Some("demo"), None, PathBuf::from("/tmp")).unwrap(); + let ctx = build_ctx(&config, Some("demo"), None, None, PathBuf::from("/tmp")).unwrap(); assert_eq!(ctx.stage, Stage::Dev); assert_eq!(ctx.auth_stage, Stage::Dev); } @@ -305,10 +348,158 @@ mod tests { &config, Some("demo"), Some("service"), + None, PathBuf::from("/tmp"), ) .unwrap(); assert_eq!(ctx.stage, Stage::Dev); assert_eq!(ctx.auth_stage, Stage::Prod); } + + #[test] + fn test_build_ctx_stage_override_replaces_stage_and_base_url() { + use cli::Stage; + use config::{Config, Profile}; + + let mut config = Config::default(); + config.profiles.insert( + "demo".to_string(), + Profile { + organization: "demonstration".to_string(), + stage: Stage::Prod, + default: None, + }, + ); + + let ctx = build_ctx( + &config, + Some("demo"), + None, + Some(&Stage::Local), + PathBuf::from("/tmp"), + ) + .unwrap(); + assert_eq!(ctx.stage, Stage::Local); + assert_eq!(ctx.base_url, "http://localhost:8080"); + } + + #[test] + fn test_build_ctx_stage_override_leaves_auth_stage_at_profile_stage() { + use cli::Stage; + use config::{Config, Profile}; + + let mut config = Config::default(); + config.profiles.insert( + "demo".to_string(), + Profile { + organization: "demonstration".to_string(), + stage: Stage::Prod, + default: None, + }, + ); + + let ctx = build_ctx( + &config, + Some("demo"), + None, + Some(&Stage::Sandbox), + PathBuf::from("/tmp"), + ) + .unwrap(); + assert_eq!(ctx.stage, Stage::Sandbox); + // Refresh must still use the tenant that issued the stored token. + assert_eq!(ctx.auth_stage, Stage::Prod); + } + + #[test] + fn test_build_ctx_stage_override_combined_with_auth_override() { + use cli::Stage; + use config::{Config, Profile}; + + let mut config = Config::default(); + config.profiles.insert( + "demo".to_string(), + Profile { + organization: "demonstration".to_string(), + stage: Stage::Dev, + default: None, + }, + ); + config.profiles.insert( + "service".to_string(), + Profile { + organization: "service".to_string(), + stage: Stage::Prod, + default: None, + }, + ); + + let ctx = build_ctx( + &config, + Some("demo"), + Some("service"), + Some(&Stage::Local), + PathBuf::from("/tmp"), + ) + .unwrap(); + assert_eq!(ctx.stage, Stage::Local); + assert_eq!(ctx.auth_stage, Stage::Prod); + assert_eq!(ctx.base_url, "http://localhost:8080"); + } + + #[test] + fn test_build_ctx_without_stage_override_uses_profile_stage() { + use cli::Stage; + use config::{Config, Profile}; + + let mut config = Config::default(); + config.profiles.insert( + "demo".to_string(), + Profile { + organization: "demonstration".to_string(), + stage: Stage::Qa, + default: None, + }, + ); + + let ctx = build_ctx(&config, Some("demo"), None, None, PathBuf::from("/tmp")).unwrap(); + assert_eq!(ctx.stage, Stage::Qa); + assert_eq!(ctx.base_url, "https://demonstration.roundingwell.com/api"); + } + + #[test] + fn test_check_stage_compatible_allows_login_without_override() { + assert!(check_stage_compatible(&login_cmd(), None).is_ok()); + } + + #[test] + fn test_check_stage_compatible_rejects_login_with_override() { + let err = check_stage_compatible(&login_cmd(), Some(&cli::Stage::Dev)).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--stage")); + assert!(msg.contains("auth login")); + } + + #[test] + fn test_check_stage_compatible_rejects_logout_with_override() { + let err = check_stage_compatible(&logout_cmd(), Some(&cli::Stage::Dev)).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("--stage")); + assert!(msg.contains("auth logout")); + } + + #[test] + fn test_check_stage_compatible_allows_status_with_override() { + assert!(check_stage_compatible(&status_cmd(), Some(&cli::Stage::Dev)).is_ok()); + } + + #[test] + fn test_check_stage_compatible_allows_header_with_override() { + assert!(check_stage_compatible(&header_cmd(), Some(&cli::Stage::Dev)).is_ok()); + } + + #[test] + fn test_check_stage_compatible_allows_other_commands_with_override() { + assert!(check_stage_compatible(&Commands::Update, Some(&cli::Stage::Dev)).is_ok()); + } }