diff --git a/Gemfile.lock b/Gemfile.lock index 4de956c2..3e85b63f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -40,7 +40,7 @@ GEM aptible-resource gem_config multipart-post (< 2.2.0) - aptible-auth (1.4.0) + aptible-auth (1.5.0) aptible-resource (~> 1.0) gem_config multipart-post (= 2.1.1) @@ -49,7 +49,7 @@ GEM 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) @@ -86,7 +86,7 @@ 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) @@ -104,7 +104,7 @@ GEM json (2.5.1) jwt (2.3.0) method_source (1.1.0) - minitest (5.12.0) + minitest (5.15.0) multi_xml (0.6.0) multipart-post (2.1.1) net-http-persistent (3.1.0) @@ -184,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/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 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 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/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/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/lib/aptible/cli/subcommands/organizations.rb b/lib/aptible/cli/subcommands/organizations.rb new file mode 100644 index 00000000..2c3027eb --- /dev/null +++ b/lib/aptible/cli/subcommands/organizations.rb @@ -0,0 +1,55 @@ +# 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' + def organizations + telemetry(__method__, options) + + user_orgs_and_roles = {} + 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' => [] + } + user_orgs_and_roles[role.organization.id]['roles'] << role + end + Formatter.render(Renderer.current) do |root| + root.list do |list| + 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) + 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 + end + end + end + end + end + end + end + end +end diff --git a/spec/aptible/cli/helpers/token_spec.rb b/spec/aptible/cli/helpers/token_spec.rb index de7cf3d7..b59621b2 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,70 @@ 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/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 diff --git a/spec/aptible/cli/subcommands/organizations_spec.rb b/spec/aptible/cli/subcommands/organizations_spec.rb new file mode 100644 index 00000000..67b29ac8 --- /dev/null +++ b/spec/aptible/cli/subcommands/organizations_spec.rb @@ -0,0 +1,90 @@ +# 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 + let(:org1) { double('org1', id: 'org-1-id', name: 'Org One') } + let(:org2) { double('org2', id: 'org-2-id', name: 'Org Two') } + 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) + 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('Id: org-2-id') + expect(captured_output_text).to include('Name: 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 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 'renders JSON output' do + allow(Aptible::CLI::Renderer).to receive(:format).and_return('json') + + subject.send('organizations') + + json = captured_output_json + expect(json).to be_a(Array) + expect(json.length).to eq(2) + + 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) + 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') + 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