From ce71c691a5bda663e10eb8b6cbf7c7fdf4748fbe Mon Sep 17 00:00:00 2001 From: "blu@blu.cx" <38222767+thebrandonlucas@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:17:14 -0500 Subject: [PATCH] feat(cli): list wallets in every organization with --all-orgs The API lists wallets one organization at a time, and each organization needs its own token, so `wallets list --all-orgs` lists the browser login's organizations, then exchanges a token and reads the wallets for each, four at a time. The results merge into one list, and each wallet gains `organization_id` and `organization_name`, which the table shows. An API key belongs to one organization, so it is refused with a hint, and `--org` or `--env` cannot be combined with the flag. When some organizations fail, the others' wallets are written and the command then fails with the first failure's exit code, naming each failed organization in `error.detail.failed_organizations`. When all fail, nothing is written, and when they all fail the same way, as for a revoked login, that error is reported with its own hint and detail. Closes #34 --- CHANGELOG.md | 1 + docs/guides/wallets.md | 12 ++ src/all_orgs.rs | 365 +++++++++++++++++++++++++++++++++++++++ src/api.rs | 22 +++ src/auth.rs | 32 ++-- src/cli.rs | 14 ++ src/error.rs | 7 +- src/lib.rs | 1 + src/output/human.rs | 3 +- src/pick.rs | 6 + src/registry.rs | 5 + src/startup.rs | 6 +- tests/cli_integration.rs | 277 +++++++++++++++++++++++++++++ 13 files changed, 737 insertions(+), 14 deletions(-) create mode 100644 src/all_orgs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 9684db1..2cf43c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes - A 403 to `VOLTAGE_API_KEY` says that the key came from that variable and where the organization came from, and suggests `--account NAME` or unsetting the variable. `--json` reports both as `error.detail.credential_sources`. - Without a picker, `payments send --invoice` without `--wallet` names the wallets in scope that match the invoice's network and currency and have funds, counting their credit limit, with `--wallet ID` and the scope flags ready to paste, or says why each wallet does not qualify. A BTC wallet's funds must cover the invoice's amount, and a USD wallet comes with the quote it needs. `--json` reports them in `error.detail`. The list is a shortlist, not a guarantee. - Without `--env`, each wallet the shortlist names comes with its own `--env`, in its flags and its quote command, so the pasted command does not fail for a missing environment. The hint names at most five wallets that do not qualify; `--json` lists all of them. +- `wallets list --all-orgs` lists the wallets in every organization a browser login can see, with an organization column, by reading the organizations in parallel. When some organizations fail, the others are listed and the command then fails, naming each failed organization in `error.detail.failed_organizations`. - The wallet picker shows a wallet's currency when its balances report one, and typing a currency filters on it. A wallet with no balance yet shows no currency. ### Changed diff --git a/docs/guides/wallets.md b/docs/guides/wallets.md index 232c641..e6d93de 100644 --- a/docs/guides/wallets.md +++ b/docs/guides/wallets.md @@ -6,6 +6,18 @@ voltage wallets list --profile prod A wallet belongs to an organization and one environment. `--profile` (or `--org` and `--env`) selects them; see [log in and use profiles](login-and-profiles.md). Payment commands take the wallet with `--wallet WALLET_ID`, or `VOLTAGE_WALLET_ID` when no `--profile` is selected, and on a terminal they offer a picker when it is missing. +## List wallets in every organization + +```sh +voltage wallets list --all-orgs --account NAME +``` + +With a browser login, `--all-orgs` lists the wallets in every organization the login can see, in one table or JSON list. Each wallet carries `organization_id` and `organization_name`, and the table shows an organization column. The command's filters, such as `--inactive true`, apply in each organization. It ignores the organization and environment of a profile or of `VOLTAGE_ORGANIZATION_ID`, and it cannot be combined with `--org` or `--env`. + +The API lists one organization at a time, and each needs its own token. So the CLI lists the login's organizations, then exchanges a token and reads the wallets for each, four organizations at a time. An API key belongs to one organization, so `--all-orgs` refuses it; use `--org ID` instead. + +When some organizations fail, the wallets of the others are still written, and the command then fails with the first failure's exit code. The error names each failed organization, and `--json` lists them in `error.detail.failed_organizations`, each with `organization_id`, `name`, `message`, and the failure's `hint` and `detail` when it has them. When every organization fails, nothing is written. When they all fail the same way, as every token exchange does for a revoked login, the command reports that error itself, such as `credential_problem: login_expired` with its `voltage login` hint. + ## Create a wallet A wallet is backed by a line of credit. Select one environment, then pass the name, the line of credit, the network, and the credit limit in the line of credit's integer base units: diff --git a/src/all_orgs.rs b/src/all_orgs.rs new file mode 100644 index 0000000..4ca5438 --- /dev/null +++ b/src/all_orgs.rs @@ -0,0 +1,365 @@ +//! `wallets list --all-orgs`: the wallets in every organization a browser login can see, in +//! one list. +//! +//! The API has no listing across organizations, and each organization's wallets need a token +//! for that organization. So the login lists its organizations, then each organization's token +//! exchange and wallet read run side by side, a few at a time. Each wallet gains its +//! organization's name, in organization order. An API key belongs to one organization, so +//! it cannot list others. +//! +//! When some organizations fail, the wallets of the others are still written, and the command +//! then fails with each failed organization in `error.detail`, so a script never mistakes a +//! partial list for a complete one. When every organization fails, nothing is written. When +//! they all fail the same way, as every exchange does for a revoked login, that error is +//! reported as is, with its own hint and detail. + +use crate::{ + Error, Result, api, + auth::{Discovery, Resolver}, + cli::{ApiInvocation, GlobalFlags}, + config::{Credential, Scope}, + error::{ErrorDetail, ErrorKind}, + output::{Envelope, Outcome, Output}, + terminal::Terminal, +}; +use futures_util::{StreamExt, stream}; +use serde::Serialize; +use serde_json::{Value, json}; +use std::borrow::Cow; +use uuid::Uuid; + +/// Organizations read at once: enough to hide the latency of a few round trips, without +/// sending the auth service a burst for a login in many organizations. +const CONCURRENCY: usize = 4; + +/// An organization whose wallets could not be listed, and why. +#[derive(Clone, Debug, Serialize)] +pub struct FailedOrganization { + pub organization_id: Uuid, + pub name: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub hint: Option, + /// The failure's own detail, such as `credential_problem` for a revoked login. + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Gives the exit code when this is the first organization that failed. + #[serde(skip)] + pub kind: ErrorKind, +} + +impl FailedOrganization { + /// The organization's failure as the error it was. + fn into_error(self) -> Error { + let error = Error::new(self.kind, self.message); + let error = match self.hint { + Some(hint) => error.with_hint(hint), + None => error, + }; + match self.detail { + Some(detail) => error.with_detail(detail), + None => error, + } + } +} + +struct Organization { + id: Uuid, + name: String, +} + +pub async fn wallets( + invocation: &ApiInvocation, + global: &GlobalFlags, + resolver: Resolver<'_>, + scope: &Scope, + out: &mut Output, +) -> Result<()> { + let Credential::Login(login) = resolver.resolve(scope).await? else { + return Err(Error::auth( + "--all-orgs needs a browser login, but the selected credential is an API key", + ) + .with_hint( + "An API key belongs to one organization: list its wallets with --org ID instead, or select a browser login with --account NAME.", + )); + }; + let listed = resolver + .list(scope, Discovery::Organizations, &login.access_token) + .await?; + let organizations = organizations(&listed); + let token = &login.access_token; + // The exchanges run side by side, so only the one line below shows progress. + let quiet = Terminal::new(true, global.no_input); + let reads = stream::iter(&organizations) + .map(|organization| async move { + let scope = Scope { + org: Some(organization.id), + account: scope.account.clone(), + ..Scope::default() + }; + let token = resolver.organization_token(&scope, token, quiet).await?; + api::organization_wallets(invocation, global, token, organization.id).await + }) + .buffered(CONCURRENCY) + .collect::>(); + let label = format!( + "Listing wallets in {} organizations...", + organizations.len() + ); + let results = global.terminal().during(label, reads).await; + let (listed, failed) = merge(&organizations, results); + if !failed.is_empty() && failed.len() == organizations.len() { + return Err(every_failed(failed)); + } + out.write( + Envelope::new(None, Value::Array(listed), None, Outcome::Retrieved), + &[], + )?; + if failed.is_empty() { + Ok(()) + } else { + Err(partial(failed, organizations.len())) + } +} + +/// The organizations the login lists that carry a UUID `id`, in the order listed. +fn organizations(listed: &Value) -> Vec { + listed + .as_array() + .or_else(|| listed.get("items").and_then(Value::as_array)) + .map(Vec::as_slice) + .unwrap_or_default() + .iter() + .filter_map(|item| { + let id = item.get("id")?.as_str()?.parse().ok()?; + let name = item.get("name").and_then(Value::as_str).unwrap_or("-"); + Some(Organization { + id, + name: name.to_owned(), + }) + }) + .collect() +} + +/// Each listed wallet with its organization's ID and name, and the organizations that failed. +fn merge( + organizations: &[Organization], + results: Vec>, +) -> (Vec, Vec) { + let mut wallets = Vec::new(); + let mut failed = Vec::new(); + for (organization, result) in organizations.iter().zip(results) { + let listed = match result { + Ok(listed) => listed, + Err(error) => { + failed.push(FailedOrganization { + organization_id: organization.id, + name: organization.name.clone(), + message: error.message, + hint: error.hint.map(Cow::into_owned), + detail: error.detail, + kind: error.kind, + }); + continue; + } + }; + let items = match listed { + Value::Array(items) => items, + Value::Object(mut page) => match page.remove("items") { + Some(Value::Array(items)) => items, + _ => Vec::new(), + }, + _ => Vec::new(), + }; + wallets.extend(items.into_iter().map(|mut wallet| { + if let Value::Object(fields) = &mut wallet { + fields.insert("organization_id".into(), json!(organization.id)); + fields.insert("organization_name".into(), json!(organization.name)); + } + wallet + })); + } + (wallets, failed) +} + +/// The error when no organization could be listed: their shared failure when they all failed +/// the same way, or else each one's. +fn every_failed(mut failed: Vec) -> Error { + let first = &failed[0]; + let same = failed + .iter() + .all(|other| other.kind == first.kind && other.message == first.message); + if same { + return failed.swap_remove(0).into_error(); + } + let total = failed.len(); + partial(failed, total) +} + +/// The error for organizations that could not be listed, once the others are written. Its +/// exit code is the first failure's, so a rejected login still exits 3. +fn partial(failed: Vec, total: usize) -> Error { + let kind = failed.first().map_or(ErrorKind::Api, |failed| failed.kind); + let names: Vec = failed + .iter() + .map(|failed| format!("{} ({})", failed.name, failed.organization_id)) + .collect(); + let hint = if failed.len() == total { + "Check the login with `voltage auth status`, or list one organization with `voltage wallets list --org ID`.".to_owned() + } else { + "The other organizations' wallets are listed. Retry, or list one organization with `voltage wallets list --org ID`.".to_owned() + }; + Error::new( + kind, + format!( + "Could not list wallets in {} of {total} organizations: {}", + failed.len(), + names.join(", ") + ), + ) + .with_hint(hint) + .with_detail(ErrorDetail::FailedOrganizations { + failed_organizations: failed, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const A: &str = "11111111-1111-4111-8111-111111111111"; + const B: &str = "22222222-2222-4222-8222-222222222222"; + + fn organization(id: &str, name: &str) -> Organization { + Organization { + id: id.parse().unwrap(), + name: name.into(), + } + } + + #[test] + fn organizations_without_a_uuid_are_skipped_and_pages_are_read() { + let listed = json!([{"id": A, "name": "Acme"}, {"id": "x"}, {"id": B}]); + let read: Vec<(Uuid, String)> = organizations(&listed) + .into_iter() + .map(|organization| (organization.id, organization.name)) + .collect(); + assert_eq!( + read, + vec![ + (A.parse().unwrap(), "Acme".into()), + (B.parse().unwrap(), "-".into()) + ] + ); + assert_eq!(organizations(&json!({"items": [{"id": A}]})).len(), 1); + assert!(organizations(&json!(null)).is_empty()); + } + + #[test] + fn merged_wallets_carry_their_organization_and_failures_are_kept() { + let organizations = [ + organization(A, "Acme"), + organization(B, "Beta"), + organization(A, "Again"), + ]; + let results = vec![ + Ok(json!([{"id": "w1"}, "not an object"])), + Err(Error::auth("denied").with_hint("check")), + Ok(json!({"items": [{"id": "w2"}]})), + ]; + let (wallets, failed) = merge(&organizations, results); + assert_eq!( + wallets, + vec![ + json!({"id": "w1", "organization_id": A, "organization_name": "Acme"}), + json!("not an object"), + json!({"id": "w2", "organization_id": A, "organization_name": "Again"}), + ] + ); + assert_eq!(failed.len(), 1); + assert_eq!( + serde_json::to_value(&failed[0]).unwrap(), + json!({"organization_id": B, "name": "Beta", "message": "denied", "hint": "check"}) + ); + assert_eq!(failed[0].kind, ErrorKind::Auth); + // A page without items, or any other body, adds nothing. + let (wallets, _) = merge( + &[organization(A, "a"), organization(B, "b")], + vec![Ok(json!({"next": null})), Ok(json!(7))], + ); + assert!(wallets.is_empty()); + } + + #[test] + fn the_error_names_each_failed_organization() { + let failed = |hint: Option<&str>| FailedOrganization { + organization_id: A.parse().unwrap(), + name: "Acme".into(), + message: "denied".into(), + hint: hint.map(str::to_owned), + detail: None, + kind: ErrorKind::Auth, + }; + let some = partial(vec![failed(None)], 2); + assert_eq!( + some.message, + format!("Could not list wallets in 1 of 2 organizations: Acme ({A})") + ); + assert_eq!(some.kind, ErrorKind::Auth); + assert!(some.hint.unwrap().starts_with("The other organizations'")); + let all = partial(vec![failed(Some("h"))], 1); + assert!(all.hint.unwrap().contains("voltage auth status")); + assert_eq!( + serde_json::to_value(all.detail.unwrap()).unwrap()["failed_organizations"][0]["hint"], + "h" + ); + } + + #[test] + fn organizations_that_all_fail_alike_report_that_one_failure() { + let failure = |id: &str, kind, message: &str| FailedOrganization { + organization_id: id.parse().unwrap(), + name: "o".into(), + message: message.into(), + hint: Some("sign in again".into()), + detail: Some(ErrorDetail::Payment(json!({"why": message}))), + kind, + }; + let alike = every_failed(vec![ + failure(A, ErrorKind::Auth, "expired"), + failure(B, ErrorKind::Auth, "expired"), + ]); + assert_eq!( + (alike.kind, alike.message.as_str()), + (ErrorKind::Auth, "expired") + ); + assert_eq!(alike.hint.as_deref(), Some("sign in again")); + assert_eq!( + serde_json::to_value(alike.detail.unwrap()).unwrap(), + json!({"why": "expired"}) + ); + // A different kind or message lists each organization instead. + for (kind, message) in [(ErrorKind::Api, "expired"), (ErrorKind::Auth, "other")] { + let mixed = every_failed(vec![ + failure(A, ErrorKind::Auth, "expired"), + failure(B, kind, message), + ]); + assert!( + mixed + .message + .starts_with("Could not list wallets in 2 of 2"), + "{}", + mixed.message + ); + let detail = serde_json::to_value(mixed.detail.unwrap()).unwrap(); + assert_eq!(detail["failed_organizations"][1]["detail"]["why"], message); + } + let without = FailedOrganization { + hint: None, + detail: None, + ..failure(A, ErrorKind::Api, "plain") + } + .into_error(); + assert!(without.hint.is_none() && without.detail.is_none()); + } +} diff --git a/src/api.rs b/src/api.rs index 343add4..5f1e0fc 100644 --- a/src/api.rs +++ b/src/api.rs @@ -640,6 +640,28 @@ pub async fn wallets( .body) } +/// One organization's wallets for `wallets list --all-orgs`, with the command's filters and +/// `--query` overrides. It runs beside the other organizations' reads, so it shows no progress. +pub async fn organization_wallets( + invocation: &ApiInvocation, + global: &GlobalFlags, + token: Secret, + organization: Uuid, +) -> Result { + let scope = Scope { + org: Some(organization), + ..Scope::default() + }; + let request = Request::build(invocation, &scope, None)?; + let credential = ApiCredential::OrganizationToken(token); + let mut api = Api::new(global, None, Authorization::Account(credential, None))?; + api.terminal = Terminal::new(true, global.no_input); + Ok(api + .send(Method::Get, &request.path, &request.query, None) + .await? + .body) +} + /// The currency of a wallet's line of credit, which decides how its payments are /// denominated and whether they need a quote. A wallet has no currency field of its own, so /// this reads the wallet for its line of credit, then the line of credit's summary. diff --git a/src/auth.rs b/src/auth.rs index becbd0b..a8bb3f0 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -888,15 +888,28 @@ impl Resolver<'_> { Credential::ApiKey(key) => return Ok(ApiCredential::ApiKey(key)), Credential::Login(login) => login, }; - let organization = scope.require_org()?; - let name = self.settings.account_name(scope)?; let token = self - .account_auth(scope)? - .exchange(&login.access_token, organization, &name) + .organization_token(scope, &login.access_token, self.flags.terminal()) .await?; Ok(ApiCredential::OrganizationToken(token)) } + /// A login's access token traded for a token whose audience is the scope's organization. + /// `terminal` shows the exchange's progress; `wallets list --all-orgs` runs several side + /// by side, so it passes a quiet one and shows one progress line for all of them. + pub async fn organization_token( + self, + scope: &Scope, + access_token: &Secret, + terminal: Terminal, + ) -> Result { + let organization = scope.require_org()?; + let name = self.settings.account_name(scope)?; + self.account_auth(scope, terminal)? + .exchange(access_token, organization, &name) + .await + } + /// List organizations visible to the login, or environments in the selected organization. pub async fn discover(self, scope: &Scope, what: Discovery) -> Result { // An API key cannot list organizations or environments; say which key was used. @@ -937,20 +950,17 @@ impl Resolver<'_> { /// One discovery listing with a token already in hand: the login's access token for /// organizations, or an organization token for environments. pub async fn list(self, scope: &Scope, what: Discovery, token: &Secret) -> Result { - let auth = self.account_auth(scope)?; + let auth = self.account_auth(scope, self.flags.terminal())?; match what { Discovery::Organizations => auth.organizations(token).await, Discovery::Environments => auth.environments(token, scope.require_org()?).await, } } - /// The auth service of the account the scope selects. - fn account_auth(self, scope: &Scope) -> Result { + /// The auth service of the account the scope selects, showing progress on `terminal`. + fn account_auth(self, scope: &Scope, terminal: Terminal) -> Result { let name = self.settings.account_name(scope)?; - AuthService::new( - &self.settings.account(&name)?.auth_url, - self.flags.terminal(), - ) + AuthService::new(&self.settings.account(&name)?.auth_url, terminal) } } diff --git a/src/cli.rs b/src/cli.rs index 5c15dff..d368291 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -342,6 +342,13 @@ struct PaginationFlags { all: bool, } +#[derive(Debug, Args)] +struct AllOrgsFlags { + /// List the wallets in every organization the browser login can see + #[arg(long, conflicts_with_all = ["org", "env"], help_heading = "Scope")] + all_orgs: bool, +} + #[derive(Debug, Args)] struct CheckoutFlags { /// Read the checkout credential from a file or - for stdin @@ -579,6 +586,8 @@ pub struct ApiInvocation { pub filters: Vec, pub query: Vec, pub all: bool, + /// `wallets list --all-orgs`: every organization the login can see, not the scope's. + pub all_orgs: bool, pub token_file: Option, pub origin: Option, pub wait: Option, @@ -733,6 +742,7 @@ fn api_invocation( let query = parse::(m)?; let execute = parse_if::(operation.method != Method::Get, m)?; let pagination = parse_if::(operation.has_parameter("limit"), m)?; + let all_orgs = parse_if::(operation.id.lists_all_organizations(), m)?; let checkout = parse_if::(operation.auth.is_checkout(), m)?; let origin = parse_if::(takes_origin(operation), m)?; let wait = parse_if::(operation.id.supports_wait(), m)?; @@ -752,6 +762,7 @@ fn api_invocation( filters, query: query.query, all: pagination.is_some_and(|flags| flags.all), + all_orgs: all_orgs.is_some_and(|flags| flags.all_orgs), token_file: checkout.and_then(|flags| flags.token_file), origin: origin.and_then(|flags| flags.origin), wait: wait.and_then(|flags| flags.wait), @@ -941,6 +952,9 @@ fn endpoint(mut cmd: ClapCommand, operation: &Operation, about: String) -> ClapC if operation.has_parameter("limit") { cmd = PaginationFlags::augment_args(cmd); } + if operation.id.lists_all_organizations() { + cmd = AllOrgsFlags::augment_args(cmd); + } if operation.auth.is_checkout() { cmd = CheckoutFlags::augment_args(cmd); } diff --git a/src/error.rs b/src/error.rs index 105dc37..aca6d4b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -90,6 +90,10 @@ pub enum ErrorDetail { /// A BOLT11 send without `--wallet`: the wallets in scope that could pay, and why the /// others cannot. WalletShortlist(crate::wallet_shortlist::Shortlist), + /// `wallets list --all-orgs`: the organizations whose wallets could not be listed. + FailedOrganizations { + failed_organizations: Vec, + }, } /// Why the auth service rejected a saved credential. `voltage context` reads no secret and @@ -144,7 +148,8 @@ impl ErrorDetail { Self::UncertainSubmission { .. } | Self::Pending { .. } | Self::Credential { .. } - | Self::WalletShortlist(_) => {} + | Self::WalletShortlist(_) + | Self::FailedOrganizations { .. } => {} } } } diff --git a/src/lib.rs b/src/lib.rs index 39b1997..b180a86 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -3,6 +3,7 @@ //! `config`, `registry`, and `secret` are public so integration tests can seed saved //! credentials and walk the operation contract; everything else is process-internal. +mod all_orgs; mod api; mod auth; mod backoff; diff --git a/src/output/human.rs b/src/output/human.rs index 8acfd13..b5a2bcb 100644 --- a/src/output/human.rs +++ b/src/output/human.rs @@ -14,9 +14,10 @@ use serde_json::{Map, Value}; use std::io::Write; /// Preferred list columns, in display order. -const COLUMNS: [&str; 12] = [ +const COLUMNS: [&str; 13] = [ "id", "name", + "organization_name", "type", "direction", "payment_kind", diff --git a/src/pick.rs b/src/pick.rs index f84e205..9106a88 100644 --- a/src/pick.rs +++ b/src/pick.rs @@ -34,6 +34,8 @@ impl Needs { fn needs(command: &Command) -> Needs { match command { + // `--all-orgs` lists every organization, so it needs none. + Command::Api(api) if api.all_orgs => Needs::default(), Command::Api(api) => { let operation = api.operation; let target = operation.target.map(|target| target.parameter_name()); @@ -239,6 +241,10 @@ mod tests { needs_of(&["wallets", "list"]), Needs::new(true, false, false) ); + assert_eq!( + needs_of(&["wallets", "list", "--all-orgs"]), + Needs::default() + ); assert_eq!( needs_of(&["payments", "list"]), Needs::new(true, true, false) diff --git a/src/registry.rs b/src/registry.rs index efa5543..f998287 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -132,6 +132,11 @@ impl OperationId { matches!(self, Self::CreatePayment | Self::CreateTreasuryMovement) } + /// `wallets list` can also list every organization the login can see (`--all-orgs`). + pub fn lists_all_organizations(self) -> bool { + self == Self::GetAllOrganizationsWalletsAsUser + } + /// The request or response is a payment that `--wait` can follow to readiness or settlement. pub fn supports_wait(self) -> bool { self.submits_payment() || self == Self::GetPayment diff --git a/src/startup.rs b/src/startup.rs index e5db0f2..c5d27c5 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -1,7 +1,7 @@ //! Process composition: parse the command line, run one command, report, and exit. use crate::{ - Error, Result, + Error, Result, all_orgs, api::{self, Execution, SubmissionState}, auth::{self, Discovery, Resolver}, cli::{ @@ -115,6 +115,10 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result }; let mut session = Session::open(&command, &global, submission).await?; match command { + Command::Api(api) if api.all_orgs => { + let mut out = output(true)?; + all_orgs::wallets(&api, &global, session.resolver(), &session.scope, &mut out).await + } Command::Api(mut api) => { // `VOLTAGE_EXECUTE` and every local check run before the wallet is read. let execution = Execution::resolve(&api, config::execute_variable()?); diff --git a/tests/cli_integration.rs b/tests/cli_integration.rs index 4ee31b8..089548f 100644 --- a/tests/cli_integration.rs +++ b/tests/cli_integration.rs @@ -5553,3 +5553,280 @@ async fn price_reports_sats_per_dollar_and_surfaces_service_rejections() { .code(1); assert!(stderr(&rejected).contains("Unsupported currency pair")); } + +/// A second organization for `wallets list --all-orgs`. +const OTHER_ORG: &str = "55555555-5555-4555-8555-555555555555"; + +/// A login that sees `ORG` (Acme) and `OTHER_ORG` (Beta), with an exchange for each. +async fn two_organizations(auth: &MockServer) { + let organizations = json!({"organizations": [ + {"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"} + ]}); + respond_once(auth, route("GET", "/users/current"), ok(organizations)).await; + organization_exchange(auth, LOGIN_ACCESS, ORG, "acme-access", 1).await; + organization_exchange(auth, LOGIN_ACCESS, OTHER_ORG, "beta-access", 1).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_merges_every_organization_the_login_sees() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + two_organizations(&auth).await; + respond_once( + &api, + route("GET", wallets_path(ORG)) + .and(header("authorization", "Bearer acme-access")) + .and(query_param("inactive", "true")), + ok(json!([{"id": WALLET, "name": "ops", "network": "signet"}])), + ) + .await; + respond_once( + &api, + route("GET", wallets_path(OTHER_ORG)) + .and(header("authorization", "Bearer beta-access")) + .and(query_param("inactive", "true")), + ok(json!([{"id": RESOURCE, "name": "treasury", "network": "mainnet"}])), + ) + .await; + + // The command's filters apply in each organization, and each wallet names its own. + let result = cli(dir.path(), &api) + .args([ + "wallets", + "list", + "--account", + LOGIN_NAME, + "--all-orgs", + "--inactive", + "true", + ]) + .assert() + .success(); + let envelope: Value = serde_json::from_str(&stdout(&result)).unwrap(); + assert_eq!( + envelope["data"], + json!([ + {"id": WALLET, "name": "ops", "network": "signet", + "organization_id": ORG, "organization_name": "Acme"}, + {"id": RESOURCE, "name": "treasury", "network": "mainnet", + "organization_id": OTHER_ORG, "organization_name": "Beta"} + ]) + ); + assert_eq!(envelope["outcome"], "retrieved"); + auth.verify().await; + api.verify().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_shows_an_organization_column_for_people() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + two_organizations(&auth).await; + for org in [ORG, OTHER_ORG] { + let id = if org == ORG { WALLET } else { RESOURCE }; + respond_once( + &api, + route("GET", wallets_path(org)), + ok(json!([{"id": id, "name": "w"}])), + ) + .await; + } + let result = Command::from_std(human_process_at(dir.path(), &api.uri())) + .args([ + "wallets", + "list", + "--account", + LOGIN_NAME, + "--all-orgs", + "--output", + "table", + ]) + .assert() + .success(); + let shown = stdout(&result); + let header = shown.lines().next().unwrap(); + assert!(header.contains("ORGANIZATION NAME"), "{shown}"); + assert!(shown.contains("Acme") && shown.contains("Beta"), "{shown}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_lists_the_others_then_fails_for_an_organization_that_fails() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + two_organizations(&auth).await; + respond_once( + &api, + route("GET", wallets_path(ORG)), + ok(json!([{"id": WALLET, "name": "ops"}])), + ) + .await; + respond_once( + &api, + route("GET", wallets_path(OTHER_ORG)), + ResponseTemplate::new(403), + ) + .await; + let args = ["wallets", "list", "--account", LOGIN_NAME, "--all-orgs"]; + + // The listed wallets are written, and the exit code is the failure's (403 is 3). + let result = cli(dir.path(), &api).args(args).assert().code(3); + let envelope: Value = serde_json::from_str(&stdout(&result)).unwrap(); + assert_eq!(envelope["data"].as_array().unwrap().len(), 1); + assert_eq!(envelope["data"][0]["organization_name"], "Acme"); + let error = json_error(&result); + assert_eq!( + error["error"]["message"], + format!("Could not list wallets in 1 of 2 organizations: Beta ({OTHER_ORG})") + ); + assert!( + error["error"]["hint"] + .as_str() + .unwrap() + .starts_with("The other organizations' wallets are listed.") + ); + let failed = &error["error"]["detail"]["failed_organizations"]; + assert_eq!(failed.as_array().unwrap().len(), 1); + assert_eq!(failed[0]["organization_id"], OTHER_ORG); + assert_eq!(failed[0]["name"], "Beta"); + assert!(failed[0]["message"].as_str().is_some(), "{failed}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_writes_nothing_when_every_organization_fails() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + two_organizations(&auth).await; + // Two different failures: each organization is named. + respond_once( + &api, + route("GET", wallets_path(ORG)), + ResponseTemplate::new(500), + ) + .await; + respond_once( + &api, + route("GET", wallets_path(OTHER_ORG)), + ResponseTemplate::new(404), + ) + .await; + let result = cli(dir.path(), &api) + .args(["wallets", "list", "--account", LOGIN_NAME, "--all-orgs"]) + .assert() + .code(1); + assert!(stdout(&result).is_empty()); + let error = json_error(&result); + assert!( + error["error"]["message"] + .as_str() + .unwrap() + .starts_with("Could not list wallets in 2 of 2 organizations"), + "{error}" + ); + assert!( + error["error"]["hint"] + .as_str() + .unwrap() + .contains("voltage auth status") + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_reports_a_revoked_login_as_itself() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + let organizations = json!({"organizations": [ + {"id": ORG, "name": "Acme"}, {"id": OTHER_ORG, "name": "Beta"} + ]}); + respond_once(&auth, route("GET", "/users/current"), ok(organizations)).await; + // The access token still lists organizations, but every exchange is refused. + respond( + &auth, + route("POST", "/oauth/token"), + ResponseTemplate::new(400).set_body_json(json!({"error": "invalid_grant"})), + 2, + ) + .await; + let result = cli(dir.path(), &api) + .args(["wallets", "list", "--account", LOGIN_NAME, "--all-orgs"]) + .assert() + .code(3); + assert!(stdout(&result).is_empty()); + let error = json_error(&result); + assert_eq!( + error["error"]["message"], + "The saved login for person has expired or was revoked" + ); + assert_eq!( + error["error"]["detail"]["credential_problem"], + "login_expired" + ); + assert_eq!( + error["error"]["hint"], + format!( + "Sign in again with `voltage login --account person --auth-url {}`.", + auth.uri() + ) + ); + assert!(api.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn wallets_list_all_orgs_with_no_organizations_is_an_empty_list() { + let auth = MockServer::start().await; + let (api, dir) = fixture().await; + seeded_login(dir.path(), &auth, false); + respond_once( + &auth, + route("GET", "/users/current"), + ok(json!({"organizations": []})), + ) + .await; + let result = cli(dir.path(), &api) + .args(["wallets", "list", "--account", LOGIN_NAME, "--all-orgs"]) + .assert() + .success(); + let envelope: Value = serde_json::from_str(&stdout(&result)).unwrap(); + assert_eq!(envelope["data"], json!([])); + assert!(api.received_requests().await.unwrap().is_empty()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn wallets_list_all_orgs_refuses_an_api_key_and_a_named_organization() { + let (api, dir) = fixture().await; + // An API key belongs to one organization. + let result = cli(dir.path(), &api) + .args(["wallets", "list", "--all-orgs"]) + .assert() + .code(3); + let error = json_error(&result); + assert_eq!( + error["error"]["message"], + "--all-orgs needs a browser login, but the selected credential is an API key" + ); + assert!( + error["error"]["hint"] + .as_str() + .unwrap() + .contains("--org ID") + ); + // --org or --env names one organization, which --all-orgs contradicts. + for scope in ["--org", "--env"] { + let result = cli(dir.path(), &api) + .args(["wallets", "list", "--all-orgs", scope, ORG]) + .assert() + .code(2); + assert!( + json_error(&result)["error"]["message"] + .as_str() + .unwrap() + .contains("cannot be used with"), + "{scope}" + ); + } + assert!(api.received_requests().await.unwrap().is_empty()); +}