From 006cb2591231d6785226d87fab81311e98e84df5 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:22:12 -0800 Subject: [PATCH 01/14] feat: aptible organizations subcommand [SC-35706] --- lib/aptible/cli/agent.rb | 2 + lib/aptible/cli/subcommands/organizations.rb | 60 ++++++ spec/aptible/cli/subcommands/organizations.rb | 0 .../cli/subcommands/organizations_spec.rb | 181 ++++++++++++++++++ 4 files changed, 243 insertions(+) create mode 100644 lib/aptible/cli/subcommands/organizations.rb create mode 100644 spec/aptible/cli/subcommands/organizations.rb create mode 100644 spec/aptible/cli/subcommands/organizations_spec.rb diff --git a/lib/aptible/cli/agent.rb b/lib/aptible/cli/agent.rb index 183be2b4..e55dfaeb 100644 --- a/lib/aptible/cli/agent.rb +++ b/lib/aptible/cli/agent.rb @@ -47,6 +47,7 @@ require_relative 'subcommands/maintenance' require_relative 'subcommands/backup_retention_policy' require_relative 'subcommands/aws_accounts' +require_relative 'subcommands/organizations' module Aptible module CLI @@ -77,6 +78,7 @@ class Agent < Thor include Subcommands::Maintenance include Subcommands::BackupRetentionPolicy include Subcommands::AwsAccounts + include Subcommands::Organizations # Forward return codes on failures. def self.exit_on_failure? diff --git a/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb new file mode 100644 index 00000000..19777f39 --- /dev/null +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Aptible + module CLI + module Subcommands + module Organizations + def self.included(thor) + thor.class_eval do + include Helpers::Token + include Helpers::Telemetry + + desc 'organizations', 'List all organizations' + option :with_organization_role, + aliases: '--with-org-role', + type: :boolean, + default: false, + desc: 'Include your role in each organization ' \ + '(requires additional API calls)' + def organizations + telemetry(__method__, options) + + token = fetch_token + orgs = Aptible::Auth::Organization.all(token: token) + + user_roles_by_org = {} + if options[:with_organization_role] + user = Aptible::Auth::User.all(token: token).first + user.roles.each do |role| + org_id = role.organization&.id rescue nil + next unless org_id + + user_roles_by_org[org_id] ||= [] + user_roles_by_org[org_id] << (role.name rescue 'unnamed') + end + end + + Formatter.render(Renderer.current) do |root| + root.list do |list| + orgs.each do |org| + list.object do |node| + node.value('id', org.id) + node.value('name', org.name) + if org.respond_to?(:handle) && org.handle + node.value('handle', org.handle) + end + if options[:with_organization_role] + roles = user_roles_by_org[org.id] || [] + node.value('role', roles.join(', ')) + end + end + end + end + end + end + end + end + end + end + end +end diff --git a/spec/aptible/cli/subcommands/organizations.rb b/spec/aptible/cli/subcommands/organizations.rb new file mode 100644 index 00000000..e69de29b diff --git a/spec/aptible/cli/subcommands/organizations_spec.rb b/spec/aptible/cli/subcommands/organizations_spec.rb new file mode 100644 index 00000000..841be10a --- /dev/null +++ b/spec/aptible/cli/subcommands/organizations_spec.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Aptible::CLI::Agent do + let(:token) { double('token') } + before { allow(subject).to receive(:fetch_token).and_return(token) } + + describe '#organizations' do + it 'lists organizations' do + org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: 'org-one') + org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: 'org-two') + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org1, org2]) + + subject.send('organizations') + + expect(captured_output_text).to include('Id: org-1-id') + expect(captured_output_text).to include('Name: Org One') + expect(captured_output_text).to include('Handle: org-one') + expect(captured_output_text).to include('Id: org-2-id') + expect(captured_output_text).to include('Name: Org Two') + expect(captured_output_text).to include('Handle: org-two') + end + + it 'handles empty list' do + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([]) + + subject.send('organizations') + + expect(captured_output_text).to eq('') + end + + it 'handles organizations without handle' do + org = double('org', id: 'org-id', name: 'Org Name') + allow(org).to receive(:respond_to?).with(:handle).and_return(false) + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org]) + + subject.send('organizations') + + expect(captured_output_text).to include('Id: org-id') + expect(captured_output_text).to include('Name: Org Name') + expect(captured_output_text).not_to include('Handle:') + end + + it 'handles organizations with nil handle' do + org = double('org', id: 'org-id', name: 'Org Name', handle: nil) + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org]) + + subject.send('organizations') + + expect(captured_output_text).to include('Id: org-id') + expect(captured_output_text).to include('Name: Org Name') + expect(captured_output_text).not_to include('Handle:') + end + + it 'renders JSON output' do + org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: 'org-one') + org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: 'org-two') + + allow(Aptible::CLI::Renderer).to receive(:format).and_return('json') + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org1, org2]) + + subject.send('organizations') + + json = captured_output_json + expect(json).to be_a(Array) + expect(json.length).to eq(2) + expect(json[0]['id']).to eq('org-1-id') + expect(json[0]['name']).to eq('Org One') + expect(json[0]['handle']).to eq('org-one') + expect(json[1]['id']).to eq('org-2-id') + expect(json[1]['name']).to eq('Org Two') + expect(json[1]['handle']).to eq('org-two') + end + + it 'does not include role by default' do + org = double('org', id: 'org-id', name: 'Org Name', handle: nil) + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org]) + + subject.send('organizations') + + expect(captured_output_text).not_to include('Role:') + end + + context 'with --with-organization-role flag' do + before { subject.options = { with_organization_role: true } } + + it 'includes user roles in each organization' do + org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: nil) + org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: nil) + + role1_org = double('role1_org', id: 'org-1-id') + role2_org = double('role2_org', id: 'org-1-id') + role3_org = double('role3_org', id: 'org-2-id') + + role1 = double('role1', name: 'Admin', organization: role1_org) + role2 = double('role2', name: 'Developer', organization: role2_org) + role3 = double('role3', name: 'Account Owners', organization: role3_org) + + user = double('user', roles: [role1, role2, role3]) + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org1, org2]) + + expect(Aptible::Auth::User).to receive(:all) + .with(token: token) + .and_return([user]) + + subject.send('organizations') + + expect(captured_output_text).to include('Id: org-1-id') + expect(captured_output_text).to include('Name: Org One') + expect(captured_output_text).to include('Role: Admin, Developer') + expect(captured_output_text).to include('Id: org-2-id') + expect(captured_output_text).to include('Name: Org Two') + expect(captured_output_text).to include('Role: Account Owners') + end + + it 'shows empty role when user has no roles in org' do + org = double('org', id: 'org-id', name: 'Org Name', handle: nil) + user = double('user', roles: []) + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org]) + + expect(Aptible::Auth::User).to receive(:all) + .with(token: token) + .and_return([user]) + + subject.send('organizations') + + expect(captured_output_text).to include('Id: org-id') + expect(captured_output_text).to include('Role:') + end + + it 'renders JSON output with roles' do + org = double('org', id: 'org-id', name: 'Org Name', handle: nil) + role_org = double('role_org', id: 'org-id') + role = double('role', name: 'Owner', organization: role_org) + user = double('user', roles: [role]) + + allow(Aptible::CLI::Renderer).to receive(:format).and_return('json') + + expect(Aptible::Auth::Organization).to receive(:all) + .with(token: token) + .and_return([org]) + + expect(Aptible::Auth::User).to receive(:all) + .with(token: token) + .and_return([user]) + + subject.send('organizations') + + json = captured_output_json + expect(json).to be_a(Array) + expect(json[0]['id']).to eq('org-id') + expect(json[0]['name']).to eq('Org Name') + expect(json[0]['role']).to eq('Owner') + end + end + end +end From 43b445a0f468f0f29dd4d7851aeba891b51c74ab Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:44:21 -0800 Subject: [PATCH 02/14] lint roller --- lib/aptible/cli/subcommands/organizations.rb | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb index 19777f39..baee1d64 100644 --- a/lib/aptible/cli/subcommands/organizations.rb +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -26,11 +26,22 @@ def organizations if options[:with_organization_role] user = Aptible::Auth::User.all(token: token).first user.roles.each do |role| - org_id = role.organization&.id rescue nil - next unless org_id + begin + org = role.organization + rescue StandardError + next + end + next unless org + + org_id = org.id + role_name = begin + role.name + rescue StandardError + 'unnamed' + end user_roles_by_org[org_id] ||= [] - user_roles_by_org[org_id] << (role.name rescue 'unnamed') + user_roles_by_org[org_id] << role_name end end From 7765e726cc776e23da71a09122224c0b9790826f Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Fri, 19 Dec 2025 17:45:58 -0800 Subject: [PATCH 03/14] bundle exec script/sync-readme-usage --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 6ec90e4d..91f52377 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Commands: aptible operation:cancel OPERATION_ID # Cancel a running operation aptible operation:follow OPERATION_ID # Follow logs of a running operation aptible operation:logs OPERATION_ID # View logs for given operation + aptible organizations # List all organizations aptible rebuild # Rebuild an app, and restart its services aptible restart # Restart all services associated with an app aptible services # List Services for an App From 5d427980fedaccaac19290df560cfee29bb936bd Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:11:25 -0800 Subject: [PATCH 04/14] use in progress version of aptible-auth --- Gemfile | 2 ++ Gemfile.lock | 17 ++++++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index 00775107..d9c9cb5e 100644 --- a/Gemfile +++ b/Gemfile @@ -7,5 +7,7 @@ group :test do gem 'webmock' end +gem 'aptible-auth', git: 'https://github.com/aptible/aptible-auth-ruby', branch: 'all-roles-for-a-user' + # Specify your gem's dependencies in aptible-cli.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index 4de956c2..7d4e8775 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,3 +1,14 @@ +GIT + remote: https://github.com/aptible/aptible-auth-ruby + revision: ab423d119aca9d1667c58a28216785ba96149949 + branch: all-roles-for-a-user + specs: + aptible-auth (1.4.0) + aptible-resource (~> 1.0) + gem_config + multipart-post (= 2.1.1) + oauth2 (= 2.0.9) + PATH remote: . specs: @@ -40,11 +51,6 @@ GEM aptible-resource gem_config multipart-post (< 2.2.0) - aptible-auth (1.4.0) - aptible-resource (~> 1.0) - gem_config - multipart-post (= 2.1.1) - oauth2 (= 2.0.9) aptible-billing (1.0.1) activesupport (>= 4.0, < 6.0) aptible-resource (~> 1.0) @@ -179,6 +185,7 @@ PLATFORMS DEPENDENCIES activesupport (~> 4.0) + aptible-auth! aptible-cli! aptible-tasks (~> 0.5.8) bundler (~> 1.3) From 41610c6536102cbdc3c3cb8c40698914f4bebb4f Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:11:55 -0800 Subject: [PATCH 05/14] =?UTF-8?q?simplify=20`aptible=20organizations`=20wi?= =?UTF-8?q?th=20user.roles=5Fwith=5Forganizations=20call=20=F0=9F=98=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/aptible/cli/subcommands/organizations.rb | 56 +++++++------------- 1 file changed, 18 insertions(+), 38 deletions(-) diff --git a/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb index baee1d64..6bdb5007 100644 --- a/lib/aptible/cli/subcommands/organizations.rb +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -10,53 +10,33 @@ def self.included(thor) include Helpers::Telemetry desc 'organizations', 'List all organizations' - option :with_organization_role, - aliases: '--with-org-role', - type: :boolean, - default: false, - desc: 'Include your role in each organization ' \ - '(requires additional API calls)' def organizations telemetry(__method__, options) - token = fetch_token - orgs = Aptible::Auth::Organization.all(token: token) - - user_roles_by_org = {} - if options[:with_organization_role] - user = Aptible::Auth::User.all(token: token).first - user.roles.each do |role| - begin - org = role.organization - rescue StandardError - next - end - next unless org - - org_id = org.id - role_name = begin - role.name - rescue StandardError - 'unnamed' - end - - user_roles_by_org[org_id] ||= [] - user_roles_by_org[org_id] << role_name - end + user = Aptible::Auth::Token.current_token(token: fetch_token).user + user_orgs_and_roles = {} + user.roles_with_organizations.each do |role| + user_orgs_and_roles[role.organization.id] ||= { + 'org' => role.organization, + 'roles' => [] + } + user_orgs_and_roles[role.organization.id]['roles'] << role end - Formatter.render(Renderer.current) do |root| root.list do |list| - orgs.each do |org| + user_orgs_and_roles.each do |org_id, org_and_role| + org = org_and_role['org'] + roles = org_and_role['roles'] list.object do |node| node.value('id', org.id) node.value('name', org.name) - if org.respond_to?(:handle) && org.handle - node.value('handle', org.handle) - end - if options[:with_organization_role] - roles = user_roles_by_org[org.id] || [] - node.value('role', roles.join(', ')) + node.list('roles') do |roles_list| + roles.each do |role| + roles_list.object do |role_node| + role_node.value('id', role.id) + role_node.value('name', role.name) + end + end end end end From 004fe88e7d9e8a555c93bd65a78a5708845d0af8 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:56:23 -0800 Subject: [PATCH 06/14] indent output for list of list --- lib/aptible/cli/renderer/text.rb | 35 ++++++++++++++++-- lib/aptible/cli/subcommands/aws_accounts.rb | 36 ++++++++----------- .../subcommands/external_aws_accounts_spec.rb | 7 ++-- 3 files changed, 50 insertions(+), 28 deletions(-) diff --git a/lib/aptible/cli/renderer/text.rb b/lib/aptible/cli/renderer/text.rb index 2b6451e9..efa35f57 100644 --- a/lib/aptible/cli/renderer/text.rb +++ b/lib/aptible/cli/renderer/text.rb @@ -32,8 +32,14 @@ def visit(node, io) # children are KeyedObject instances so they can render properly, # but we need to warn in tests that this is required. node.children.each_pair do |k, c| - io.print "#{format_key(k)}: " - visit(c, io) + io.print "#{format_key(k)}:" + if c.is_a?(Formatter::List) + io.puts + visit_indented(c, io, ' ') + else + io.print ' ' + visit(c, io) + end end when Formatter::GroupedKeyedList enum = spacer_enumerator @@ -65,6 +71,31 @@ def render(node) private + def visit_indented(node, io, indent) + return unless node.is_a?(Formatter::List) + + node.children.each do |child| + case child + when Formatter::Object + child.children.each_pair do |k, c| + io.print "#{indent}#{format_key(k)}:" + if c.is_a?(Formatter::List) + io.puts + visit_indented(c, io, indent + ' ') + else + io.print ' ' + visit(c, io) + end + end + io.puts unless child == node.children.last + when Formatter::Value + io.puts "#{indent}#{child.value}" + else + visit(child, io) + end + end + end + def output_list(nodes, io) if nodes.all? { |v| v.is_a?(Formatter::Value) } # All nodes are single values, so we render one per line. diff --git a/lib/aptible/cli/subcommands/aws_accounts.rb b/lib/aptible/cli/subcommands/aws_accounts.rb index 62993009..8799920f 100644 --- a/lib/aptible/cli/subcommands/aws_accounts.rb +++ b/lib/aptible/cli/subcommands/aws_accounts.rb @@ -220,32 +220,24 @@ def aws_accounts response = check_external_aws_account!(id) - if Renderer.format == 'json' - Formatter.render(Renderer.current) do |root| - root.object do |node| - node.value('state', response.state) - node.list('checks') do |check_list| - response.checks.each do |check| - check_list.object do |check_node| - check_node.value('name', check.check_name) - check_node.value('state', check.state) - check_node.value('details', check.details) \ - unless check.details.nil? - end + fmt_state = lambda do |state| + Renderer.format == 'json' ? state : format_check_state(state) + end + + Formatter.render(Renderer.current) do |root| + root.object do |node| + node.value('state', fmt_state.call(response.state)) + node.list('checks') do |check_list| + response.checks.each do |check| + check_list.object do |check_node| + check_node.value('name', check.check_name) + check_node.value('state', fmt_state.call(check.state)) + check_node.value('details', check.details) \ + unless check.details.nil? end end end end - else - puts "State: #{format_check_state(response.state)}" - puts '' - puts 'Checks:' - response.checks.each do |check| - puts " Name: #{check.check_name}" - puts " State: #{format_check_state(check.state)}" - puts " Details: #{check.details}" unless check.details.nil? - puts '' - end end unless response.state == 'success' diff --git a/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb b/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb index 5f1e829a..69591322 100644 --- a/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb +++ b/spec/aptible/cli/subcommands/external_aws_accounts_spec.rb @@ -692,10 +692,9 @@ .with('42', token: token).and_return(ext) expect(ext).to receive(:check!).and_return(check_result) - # check command uses puts directly (not Formatter) for non-JSON output - expect { subject.send('aws_accounts:check', '42') }.to output( - /State:.*success/m - ).to_stdout + subject.send('aws_accounts:check', '42') + + expect(captured_output_text).to match(/State:.*success/m) end it 'raises error on check failure' do From a8ee1f13b3b28393e1c24bc9e20dbb666bfb9ce1 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 22 Dec 2025 23:58:29 -0800 Subject: [PATCH 07/14] lint roller --- lib/aptible/cli/subcommands/organizations.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb index 6bdb5007..616d0d46 100644 --- a/lib/aptible/cli/subcommands/organizations.rb +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -28,7 +28,7 @@ def organizations org = org_and_role['org'] roles = org_and_role['roles'] list.object do |node| - node.value('id', org.id) + node.value('id', org_id) node.value('name', org.name) node.list('roles') do |roles_list| roles.each do |role| From 3cfa5461973942c893089beb7cd7f12061289059 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:07:24 -0800 Subject: [PATCH 08/14] more tests yay --- .../cli/subcommands/organizations_spec.rb | 170 +++--------------- 1 file changed, 27 insertions(+), 143 deletions(-) diff --git a/spec/aptible/cli/subcommands/organizations_spec.rb b/spec/aptible/cli/subcommands/organizations_spec.rb index 841be10a..0e4aa3a7 100644 --- a/spec/aptible/cli/subcommands/organizations_spec.rb +++ b/spec/aptible/cli/subcommands/organizations_spec.rb @@ -7,175 +7,59 @@ before { allow(subject).to receive(:fetch_token).and_return(token) } describe '#organizations' do - it 'lists organizations' do - org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: 'org-one') - org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: 'org-two') - - expect(Aptible::Auth::Organization).to receive(:all) + let(:org1) { double('org1', id: 'org-1-id', name: 'Org One') } + let(:org2) { double('org2', id: 'org-2-id', name: 'Org Two') } + let(:role1) { double('role1', id: 'role-1-id', name: 'Admin', organization: org1) } + let(:role2) { double('role2', id: 'role-2-id', name: 'Developer', organization: org1) } + let(:role3) { double('role3', id: 'role-3-id', name: 'Account Owners', organization: org2) } + let(:user) { double('user', roles_with_organizations: [role1, role2, role3]) } + let(:current_token) { double('current_token', user: user) } + + before do + allow(Aptible::Auth::Token).to receive(:current_token) .with(token: token) - .and_return([org1, org2]) + .and_return(current_token) + end + it 'lists organizations with roles' do subject.send('organizations') expect(captured_output_text).to include('Id: org-1-id') expect(captured_output_text).to include('Name: Org One') - expect(captured_output_text).to include('Handle: org-one') expect(captured_output_text).to include('Id: org-2-id') expect(captured_output_text).to include('Name: Org Two') - expect(captured_output_text).to include('Handle: org-two') + expect(captured_output_text).to include('Roles:') + expect(captured_output_text).to include('Name: Admin') + expect(captured_output_text).to include('Name: Developer') + expect(captured_output_text).to include('Name: Account Owners') end - it 'handles empty list' do - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([]) + it 'handles user with no roles' do + allow(user).to receive(:roles_with_organizations).and_return([]) subject.send('organizations') expect(captured_output_text).to eq('') end - it 'handles organizations without handle' do - org = double('org', id: 'org-id', name: 'Org Name') - allow(org).to receive(:respond_to?).with(:handle).and_return(false) - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org]) - - subject.send('organizations') - - expect(captured_output_text).to include('Id: org-id') - expect(captured_output_text).to include('Name: Org Name') - expect(captured_output_text).not_to include('Handle:') - end - - it 'handles organizations with nil handle' do - org = double('org', id: 'org-id', name: 'Org Name', handle: nil) - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org]) - - subject.send('organizations') - - expect(captured_output_text).to include('Id: org-id') - expect(captured_output_text).to include('Name: Org Name') - expect(captured_output_text).not_to include('Handle:') - end - it 'renders JSON output' do - org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: 'org-one') - org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: 'org-two') - allow(Aptible::CLI::Renderer).to receive(:format).and_return('json') - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org1, org2]) - subject.send('organizations') json = captured_output_json expect(json).to be_a(Array) expect(json.length).to eq(2) - expect(json[0]['id']).to eq('org-1-id') - expect(json[0]['name']).to eq('Org One') - expect(json[0]['handle']).to eq('org-one') - expect(json[1]['id']).to eq('org-2-id') - expect(json[1]['name']).to eq('Org Two') - expect(json[1]['handle']).to eq('org-two') - end - - it 'does not include role by default' do - org = double('org', id: 'org-id', name: 'Org Name', handle: nil) - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org]) - - subject.send('organizations') - - expect(captured_output_text).not_to include('Role:') - end - - context 'with --with-organization-role flag' do - before { subject.options = { with_organization_role: true } } - - it 'includes user roles in each organization' do - org1 = double('org1', id: 'org-1-id', name: 'Org One', handle: nil) - org2 = double('org2', id: 'org-2-id', name: 'Org Two', handle: nil) - - role1_org = double('role1_org', id: 'org-1-id') - role2_org = double('role2_org', id: 'org-1-id') - role3_org = double('role3_org', id: 'org-2-id') - - role1 = double('role1', name: 'Admin', organization: role1_org) - role2 = double('role2', name: 'Developer', organization: role2_org) - role3 = double('role3', name: 'Account Owners', organization: role3_org) - - user = double('user', roles: [role1, role2, role3]) - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org1, org2]) - - expect(Aptible::Auth::User).to receive(:all) - .with(token: token) - .and_return([user]) - - subject.send('organizations') - - expect(captured_output_text).to include('Id: org-1-id') - expect(captured_output_text).to include('Name: Org One') - expect(captured_output_text).to include('Role: Admin, Developer') - expect(captured_output_text).to include('Id: org-2-id') - expect(captured_output_text).to include('Name: Org Two') - expect(captured_output_text).to include('Role: Account Owners') - end - - it 'shows empty role when user has no roles in org' do - org = double('org', id: 'org-id', name: 'Org Name', handle: nil) - user = double('user', roles: []) - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org]) - - expect(Aptible::Auth::User).to receive(:all) - .with(token: token) - .and_return([user]) - - subject.send('organizations') - - expect(captured_output_text).to include('Id: org-id') - expect(captured_output_text).to include('Role:') - end - - it 'renders JSON output with roles' do - org = double('org', id: 'org-id', name: 'Org Name', handle: nil) - role_org = double('role_org', id: 'org-id') - role = double('role', name: 'Owner', organization: role_org) - user = double('user', roles: [role]) - - allow(Aptible::CLI::Renderer).to receive(:format).and_return('json') - - expect(Aptible::Auth::Organization).to receive(:all) - .with(token: token) - .and_return([org]) - - expect(Aptible::Auth::User).to receive(:all) - .with(token: token) - .and_return([user]) - subject.send('organizations') + org1_json = json.find { |o| o['id'] == 'org-1-id' } + expect(org1_json['name']).to eq('Org One') + expect(org1_json['roles'].length).to eq(2) + expect(org1_json['roles'].map { |r| r['name'] }).to contain_exactly('Admin', 'Developer') - json = captured_output_json - expect(json).to be_a(Array) - expect(json[0]['id']).to eq('org-id') - expect(json[0]['name']).to eq('Org Name') - expect(json[0]['role']).to eq('Owner') - end + org2_json = json.find { |o| o['id'] == 'org-2-id' } + expect(org2_json['name']).to eq('Org Two') + expect(org2_json['roles'].length).to eq(1) + expect(org2_json['roles'][0]['name']).to eq('Account Owners') end end end From c213f1fc3c6ab2103d448a17fa7e9594e6955ff9 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:12:21 -0800 Subject: [PATCH 09/14] whoami helper method, plus more testing! --- lib/aptible/cli/helpers/token.rb | 14 ++++ lib/aptible/cli/subcommands/organizations.rb | 8 ++- spec/aptible/cli/helpers/token_spec.rb | 68 +++++++++++++++++++ .../cli/subcommands/organizations_spec.rb | 23 +++++-- 4 files changed, 107 insertions(+), 6 deletions(-) diff --git a/lib/aptible/cli/helpers/token.rb b/lib/aptible/cli/helpers/token.rb index 7c1e0d91..24f23b97 100644 --- a/lib/aptible/cli/helpers/token.rb +++ b/lib/aptible/cli/helpers/token.rb @@ -52,6 +52,20 @@ def decode_token tok = fetch_token JWT.decode(tok, nil, false) end + + # Instance of Aptible::Auth::Token from current token + def current_token + Aptible::Auth::Token.current_token(token: fetch_token) + rescue HyperResource::ClientError => e + raise Thor::Error, e.message + end + + # Instance of Aptible::Auth::User associated with current token + def whoami + current_token.user + rescue HyperResource::ClientError => e + raise Thor::Error, e.message + end end end end diff --git a/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb index 616d0d46..2c3027eb 100644 --- a/lib/aptible/cli/subcommands/organizations.rb +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -13,9 +13,13 @@ def self.included(thor) def organizations telemetry(__method__, options) - user = Aptible::Auth::Token.current_token(token: fetch_token).user user_orgs_and_roles = {} - user.roles_with_organizations.each do |role| + begin + roles = whoami.roles_with_organizations + rescue HyperResource::ClientError => e + raise Thor::Error, e.message + end + roles.each do |role| user_orgs_and_roles[role.organization.id] ||= { 'org' => role.organization, 'roles' => [] diff --git a/spec/aptible/cli/helpers/token_spec.rb b/spec/aptible/cli/helpers/token_spec.rb index de7cf3d7..b667773e 100644 --- a/spec/aptible/cli/helpers/token_spec.rb +++ b/spec/aptible/cli/helpers/token_spec.rb @@ -7,6 +7,10 @@ subject { Class.new.send(:include, described_class).new } + let(:token) { 'test-token' } + let(:user) { double('user', id: 'user-id', email: 'test@example.com') } + let(:auth_token) { double('auth_token', user: user) } + describe '#save_token / #fetch_token' do it 'reads back a token it saved' do subject.save_token('foo') @@ -38,4 +42,68 @@ end end end + + describe '#current_token' do + before do + subject.save_token(token) + end + + it 'returns the current auth token' do + expect(Aptible::Auth::Token).to receive(:current_token) + .with(token: token) + .and_return(auth_token) + + expect(subject.current_token).to eq(auth_token) + end + + it 'raises Thor::Error on 401 unauthorized' do + response = Faraday::Response.new(status: 401) + error = HyperResource::ClientError.new('401 (invalid_token) Invalid Token', + response: response) + expect(Aptible::Auth::Token).to receive(:current_token) + .with(token: token) + .and_raise(error) + + expect { subject.current_token } + .to raise_error(Thor::Error, /Invalid Token/) + end + + it 'raises Thor::Error on 403 forbidden' do + response = Faraday::Response.new(status: 403) + error = HyperResource::ClientError.new('403 (forbidden) Access denied', + response: response) + expect(Aptible::Auth::Token).to receive(:current_token) + .with(token: token) + .and_raise(error) + + expect { subject.current_token } + .to raise_error(Thor::Error, /Access denied/) + end + end + + describe '#whoami' do + before do + subject.save_token(token) + end + + it 'returns the current user' do + expect(Aptible::Auth::Token).to receive(:current_token) + .with(token: token) + .and_return(auth_token) + + expect(subject.whoami).to eq(user) + end + + it 'raises Thor::Error on API error' do + response = Faraday::Response.new(status: 401) + error = HyperResource::ClientError.new('401 (invalid_token) Invalid Token', + response: response) + expect(Aptible::Auth::Token).to receive(:current_token) + .with(token: token) + .and_raise(error) + + expect { subject.whoami } + .to raise_error(Thor::Error, /Invalid Token/) + end + end end diff --git a/spec/aptible/cli/subcommands/organizations_spec.rb b/spec/aptible/cli/subcommands/organizations_spec.rb index 0e4aa3a7..8ae8827c 100644 --- a/spec/aptible/cli/subcommands/organizations_spec.rb +++ b/spec/aptible/cli/subcommands/organizations_spec.rb @@ -13,12 +13,9 @@ let(:role2) { double('role2', id: 'role-2-id', name: 'Developer', organization: org1) } let(:role3) { double('role3', id: 'role-3-id', name: 'Account Owners', organization: org2) } let(:user) { double('user', roles_with_organizations: [role1, role2, role3]) } - let(:current_token) { double('current_token', user: user) } before do - allow(Aptible::Auth::Token).to receive(:current_token) - .with(token: token) - .and_return(current_token) + allow(subject).to receive(:whoami).and_return(user) end it 'lists organizations with roles' do @@ -61,5 +58,23 @@ expect(org2_json['roles'].length).to eq(1) expect(org2_json['roles'][0]['name']).to eq('Account Owners') end + + it 'raises Thor::Error on whoami API error' do + allow(subject).to receive(:whoami) + .and_raise(Thor::Error, '401 (invalid_token) Invalid Token') + + expect { subject.send('organizations') } + .to raise_error(Thor::Error, /Invalid Token/) + end + + it 'raises Thor::Error on roles_with_organizations API error' do + response = Faraday::Response.new(status: 403) + error = HyperResource::ClientError.new('403 (forbidden) Access denied', + response: response) + allow(user).to receive(:roles_with_organizations).and_raise(error) + + expect { subject.send('organizations') } + .to raise_error(Thor::Error, /Access denied/) + end end end From 90df0a7fb1ea06487de7d59a78e03f7abf73090b Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:17:29 -0800 Subject: [PATCH 10/14] lint roller --- spec/aptible/cli/helpers/token_spec.rb | 10 ++++++---- .../cli/subcommands/organizations_spec.rb | 20 ++++++++++++++----- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/spec/aptible/cli/helpers/token_spec.rb b/spec/aptible/cli/helpers/token_spec.rb index b667773e..b59621b2 100644 --- a/spec/aptible/cli/helpers/token_spec.rb +++ b/spec/aptible/cli/helpers/token_spec.rb @@ -58,8 +58,9 @@ it 'raises Thor::Error on 401 unauthorized' do response = Faraday::Response.new(status: 401) - error = HyperResource::ClientError.new('401 (invalid_token) Invalid Token', - response: response) + error = HyperResource::ClientError.new( + '401 (invalid_token) Invalid Token', response: response + ) expect(Aptible::Auth::Token).to receive(:current_token) .with(token: token) .and_raise(error) @@ -96,8 +97,9 @@ it 'raises Thor::Error on API error' do response = Faraday::Response.new(status: 401) - error = HyperResource::ClientError.new('401 (invalid_token) Invalid Token', - response: response) + error = HyperResource::ClientError.new( + '401 (invalid_token) Invalid Token', response: response + ) expect(Aptible::Auth::Token).to receive(:current_token) .with(token: token) .and_raise(error) diff --git a/spec/aptible/cli/subcommands/organizations_spec.rb b/spec/aptible/cli/subcommands/organizations_spec.rb index 8ae8827c..67b29ac8 100644 --- a/spec/aptible/cli/subcommands/organizations_spec.rb +++ b/spec/aptible/cli/subcommands/organizations_spec.rb @@ -9,10 +9,19 @@ describe '#organizations' do let(:org1) { double('org1', id: 'org-1-id', name: 'Org One') } let(:org2) { double('org2', id: 'org-2-id', name: 'Org Two') } - let(:role1) { double('role1', id: 'role-1-id', name: 'Admin', organization: org1) } - let(:role2) { double('role2', id: 'role-2-id', name: 'Developer', organization: org1) } - let(:role3) { double('role3', id: 'role-3-id', name: 'Account Owners', organization: org2) } - let(:user) { double('user', roles_with_organizations: [role1, role2, role3]) } + let(:role1) do + double('role1', id: 'role-1-id', name: 'Admin', organization: org1) + end + let(:role2) do + double('role2', id: 'role-2-id', name: 'Developer', organization: org1) + end + let(:role3) do + double('role3', id: 'role-3-id', name: 'Account Owners', + organization: org2) + end + let(:user) do + double('user', roles_with_organizations: [role1, role2, role3]) + end before do allow(subject).to receive(:whoami).and_return(user) @@ -51,7 +60,8 @@ org1_json = json.find { |o| o['id'] == 'org-1-id' } expect(org1_json['name']).to eq('Org One') expect(org1_json['roles'].length).to eq(2) - expect(org1_json['roles'].map { |r| r['name'] }).to contain_exactly('Admin', 'Developer') + role_names = org1_json['roles'].map { |r| r['name'] } + expect(role_names).to contain_exactly('Admin', 'Developer') org2_json = json.find { |o| o['id'] == 'org-2-id' } expect(org2_json['name']).to eq('Org Two') From 744c400218ca380f6935a715cd21a6179283a307 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Tue, 23 Dec 2025 00:20:42 -0800 Subject: [PATCH 11/14] bundle update aptible-auth --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 7d4e8775..bcb9ee16 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ GIT remote: https://github.com/aptible/aptible-auth-ruby - revision: ab423d119aca9d1667c58a28216785ba96149949 + revision: e67225d62994219fc2b186d3dba8b061acecbf75 branch: all-roles-for-a-user specs: - aptible-auth (1.4.0) + aptible-auth (1.5.0) aptible-resource (~> 1.0) gem_config multipart-post (= 2.1.1) From e3f456b3f3ddb5302fe4a162a955219f96a69cc9 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:56:14 -0800 Subject: [PATCH 12/14] aptible-auth 1.5.0 --- Gemfile | 2 -- Gemfile.lock | 27 +++++++++++---------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/Gemfile b/Gemfile index d9c9cb5e..00775107 100644 --- a/Gemfile +++ b/Gemfile @@ -7,7 +7,5 @@ group :test do gem 'webmock' end -gem 'aptible-auth', git: 'https://github.com/aptible/aptible-auth-ruby', branch: 'all-roles-for-a-user' - # Specify your gem's dependencies in aptible-cli.gemspec gemspec diff --git a/Gemfile.lock b/Gemfile.lock index bcb9ee16..1e25bae1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,14 +1,3 @@ -GIT - remote: https://github.com/aptible/aptible-auth-ruby - revision: e67225d62994219fc2b186d3dba8b061acecbf75 - branch: all-roles-for-a-user - specs: - aptible-auth (1.5.0) - aptible-resource (~> 1.0) - gem_config - multipart-post (= 2.1.1) - oauth2 (= 2.0.9) - PATH remote: . specs: @@ -51,11 +40,16 @@ GEM aptible-resource gem_config multipart-post (< 2.2.0) + aptible-auth (1.5.0) + aptible-resource (~> 1.0) + gem_config + multipart-post (= 2.1.1) + oauth2 (= 2.0.9) aptible-billing (1.0.1) activesupport (>= 4.0, < 6.0) aptible-resource (~> 1.0) stripe (>= 1.13.0) - aptible-resource (1.1.3) + aptible-resource (1.1.4) activesupport fridge gem_config (~> 0.3.1) @@ -92,14 +86,15 @@ GEM fabrication (2.15.2) faraday (0.17.6) multipart-post (>= 1.2, < 3) - fridge (1.0.0) + fridge (1.0.1) gem_config jwt (~> 2.3.0) gem_config (0.3.2) git (1.7.0) rchardet (~> 1.8) hashdiff (1.1.1) - hashie (5.0.0) + hashie (5.1.0) + logger httpclient (2.8.3) httplog (1.5.0) rack (>= 1.0) @@ -109,8 +104,9 @@ GEM jmespath (1.6.2) json (2.5.1) jwt (2.3.0) + logger (1.7.0) method_source (1.1.0) - minitest (5.12.0) + minitest (5.26.1) multi_xml (0.6.0) multipart-post (2.1.1) net-http-persistent (3.1.0) @@ -185,7 +181,6 @@ PLATFORMS DEPENDENCIES activesupport (~> 4.0) - aptible-auth! aptible-cli! aptible-tasks (~> 0.5.8) bundler (~> 1.3) From 6c0b66941e86c082b9f688471a5e94f8dad9b531 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 29 Dec 2025 13:57:27 -0800 Subject: [PATCH 13/14] oops rm empty file --- spec/aptible/cli/subcommands/organizations.rb | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 spec/aptible/cli/subcommands/organizations.rb diff --git a/spec/aptible/cli/subcommands/organizations.rb b/spec/aptible/cli/subcommands/organizations.rb deleted file mode 100644 index e69de29b..00000000 From d3fc001957d02e6d6be510d304926dd64830eca9 Mon Sep 17 00:00:00 2001 From: T Van Doren <210452321+tvdaptible@users.noreply.github.com> Date: Mon, 29 Dec 2025 14:04:27 -0800 Subject: [PATCH 14/14] tighten up some dep requirements --- Gemfile.lock | 8 ++++---- aptible-cli.gemspec | 2 ++ 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 1e25bae1..3e85b63f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -93,8 +93,7 @@ GEM git (1.7.0) rchardet (~> 1.8) hashdiff (1.1.1) - hashie (5.1.0) - logger + hashie (5.0.0) httpclient (2.8.3) httplog (1.5.0) rack (>= 1.0) @@ -104,9 +103,8 @@ GEM jmespath (1.6.2) json (2.5.1) jwt (2.3.0) - logger (1.7.0) method_source (1.1.0) - minitest (5.26.1) + minitest (5.15.0) multi_xml (0.6.0) multipart-post (2.1.1) net-http-persistent (3.1.0) @@ -186,7 +184,9 @@ DEPENDENCIES bundler (~> 1.3) climate_control (= 0.0.3) fabrication (~> 2.15.2) + hashie (< 5.1) httplog (< 1.6) + minitest (< 5.16) pry rack (~> 1.0) rake diff --git a/aptible-cli.gemspec b/aptible-cli.gemspec index b29fc506..0d617bff 100644 --- a/aptible-cli.gemspec +++ b/aptible-cli.gemspec @@ -55,4 +55,6 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'climate_control', '= 0.0.3' spec.add_development_dependency 'fabrication', '~> 2.15.2' spec.add_development_dependency 'httplog', '< 1.6' + spec.add_development_dependency 'minitest', '< 5.16' + spec.add_development_dependency 'hashie', '< 5.1' end