diff --git a/bin/action-account b/bin/action-account new file mode 100755 index 00000000000..0ecba78cfd2 --- /dev/null +++ b/bin/action-account @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby + +ENV['LOGIN_TASK_LOG_LEVEL'] ||= 'warn' +require_relative '../config/environment.rb' +require 'action_account' +ActionAccount.new(argv: ARGV.dup, stdout: STDOUT, stderr: STDERR).run diff --git a/lib/action_account.rb b/lib/action_account.rb new file mode 100644 index 00000000000..9326118e916 --- /dev/null +++ b/lib/action_account.rb @@ -0,0 +1,144 @@ +require_relative './script_base' + +class ActionAccount + attr_reader :argv, :stdout, :stderr + + def initialize(argv:, stdout:, stderr:) + @argv = argv + @stdout = stdout + @stderr = stderr + end + + def script_base + @script_base ||= ScriptBase.new( + argv:, + stdout:, + stderr:, + subtask_class: subtask(argv.shift), + banner: banner, + ) + end + + def run + script_base.run + end + + def banner + basename = File.basename($PROGRAM_NAME) + <<~EOS + #{basename} [subcommand] [arguments] [options] + Example usage: + + * #{basename} review-reject uuid1 uuid2 + + * #{basename} review-pass uuid1 uuid2 + + Options: + EOS + end + + # @api private + # A subtask is a class that has a run method, the type signature should look like: + # +#run(args: Array, config: Config) -> Result+ + # @return [Class,nil] + def subtask(name) + { + 'review-reject' => ReviewReject, + 'review-pass' => ReviewPass, + }[name] + end + + class ReviewReject + def run(args:, config:) + uuids = args + + users = User.where(uuid: uuids).order(:uuid) + + table = [] + table << %w[uuid status] + + users.each do |user| + if !user.fraud_review_pending? + table << [user.uuid, 'Error: User does not have a pending fraud review'] + next + end + + if FraudReviewChecker.new(user).fraud_review_eligible? + profile = user.fraud_review_pending_profile + profile.reject_for_fraud(notify_user: true) + + table << [user.uuid, "User's profile has been deactivated due to fraud rejection."] + else + table << [user.uuid, 'User is past the 30 day review eligibility'] + end + end + + if config.include_missing? + (uuids - users.map(&:uuid)).each do |missing_uuid| + table << [missing_uuid, 'Error: Could not find user with that UUID'] + end + end + + ScriptBase::Result.new( + subtask: 'review-reject', + uuids: users.map(&:uuid), + table:, + ) + end + end + + class ReviewPass + def run(args:, config:) + uuids = args + + users = User.where(uuid: uuids).order(:uuid) + + table = [] + table << %w[uuid status] + + users.each do |user| + if !user.fraud_review_pending? + table << [user.uuid, 'Error: User does not have a pending fraud review'] + next + end + + if FraudReviewChecker.new(user).fraud_review_eligible? + profile = user.fraud_review_pending_profile + profile.activate_after_passing_review + + if profile.active? + event, _disavowal_token = UserEventCreator.new(current_user: user). + create_out_of_band_user_event(:account_verified) + + UserAlerts::AlertUserAboutAccountVerified.call( + user: user, + date_time: event.created_at, + sp_name: nil, + ) + + table << [user.uuid, "User's profile has been activated and the user has been emailed."] + else + table << [ + user.uuid, + "There was an error activating the user's profile. Please try again", + ] + end + else + table << [user.uuid, 'User is past the 30 day review eligibility'] + end + end + + if config.include_missing? + (uuids - users.map(&:uuid)).each do |missing_uuid| + table << [missing_uuid, 'Error: Could not find user with that UUID'] + end + end + + ScriptBase::Result.new( + subtask: 'review-pass', + uuids: users.map(&:uuid), + table:, + ) + end + end +end diff --git a/lib/data_pull.rb b/lib/data_pull.rb index a7d876cb558..f51cae050d0 100644 --- a/lib/data_pull.rb +++ b/lib/data_pull.rb @@ -1,4 +1,4 @@ -require 'optparse' +require_relative './script_base' class DataPull attr_reader :argv, :stdout, :stderr @@ -9,91 +9,39 @@ def initialize(argv:, stdout:, stderr:) @stderr = stderr end - Result = Struct.new( - :table, # tabular output, rendered as an ASCII table or as CSV - :json, # output that should only be formatted as JSON - :subtask, # name of subtask, used for audit logging - :uuids, # Array of UUIDs entered or returned, used for audit logging - keyword_init: true, - ) - - Config = Struct.new( - :include_missing, - :format, - :show_help, - :requesting_issuers, - keyword_init: true, - ) do - alias_method :include_missing?, :include_missing - alias_method :show_help?, :show_help - end - - def config - @config ||= Config.new( - include_missing: true, - format: :table, - show_help: false, - requesting_issuers: [], + def script_base + @script_base ||= ScriptBase.new( + argv:, + stdout:, + stderr:, + subtask_class: subtask(argv.shift), + banner: banner, ) end def run - option_parser.parse!(argv) - subtask_class = subtask(argv.shift) + script_base.run + end - if config.show_help? || !subtask_class - stderr.puts '*Task*: `help`' - stderr.puts '*UUIDs*: N/A' + def banner + basename = File.basename($PROGRAM_NAME) + <<~EOS + #{basename} [subcommand] [arguments] [options] - stdout.puts option_parser - return - end + Example usage: - result = subtask_class.new.run(args: argv, config:) + * #{basename} uuid-lookup email1@example.com email2@example.com - stderr.puts "*Task*: `#{result.subtask}`" - stderr.puts "*UUIDs*: #{result.uuids.map { |uuid| "`#{uuid}`" }.join(', ')}" + * #{basename} uuid-convert partner-uuid1 partner-uuid2 - if result.json - stdout.puts result.json.to_json - else - render_output(result.table) - end - end + * #{basename} email-lookup uuid1 uuid2 - # @param [Array>] rows - def render_output(rows) - return if rows.blank? - - case config.format - when :table - require 'terminal-table' - table = Terminal::Table.new - header, *body = rows - table << header - table << :separator - body.each do |row| - table << row - end - stdout.puts table - when :csv - require 'csv' - CSV.instance(stdout) do |csv| - rows.each do |row| - csv << row - end - end - when :json - headers, *body = rows + * #{basename} ig-request uuid1 uuid2 --requesting-issuer ABC:DEF:GHI - objects = body.map do |values| - headers.zip(values).to_h - end + * #{basename} profile-summary uuid1 uuid2 - stdout.puts JSON.pretty_generate(objects) - else - raise "Unknown format=#{config.format}" - end + Options: + EOS end # @api private @@ -110,60 +58,6 @@ def subtask(name) }[name] end - # rubocop:disable Metrics/BlockLength - def option_parser - basename = File.basename($PROGRAM_NAME) - - @option_parser ||= OptionParser.new do |opts| - opts.banner = <<~EOS - #{basename} [subcommand] [arguments] [options] - - Example usage: - - * #{basename} uuid-lookup email1@example.com email2@example.com - - * #{basename} uuid-convert partner-uuid1 partner-uuid2 - - * #{basename} email-lookup uuid1 uuid2 - - * #{basename} ig-request uuid1 uuid2 --requesting-issuer ABC:DEF:GHI - - * #{basename} profile-summary uuid1 uuid2 - - Options: - EOS - - opts.on('-i=ISSUER', '--requesting-issuer=ISSUER', <<-MSG) do |issuer| - requesting issuer (used for ig-request task) - MSG - config.requesting_issuers << issuer - end - - opts.on('--help') do - config.show_help = true - end - - opts.on('--csv') do - config.format = :csv - end - - opts.on('--table', 'Output format as an ASCII table (default)') do - config.format = :table - end - - opts.on('--json') do - config.format = :json - end - - opts.on('--[no-]include-missing', <<~STR) do |include_missing| - Whether or not to add rows in the output for missing inputs, defaults to on - STR - config.include_missing = include_missing - end - end - end - # rubocop:enable Metrics/BlockLength - class UuidLookup def run(args:, config:) emails = args @@ -183,7 +77,7 @@ def run(args:, config:) end end - Result.new( + ScriptBase::Result.new( subtask: 'uuid-lookup', table:, uuids:, @@ -209,7 +103,7 @@ def run(args:, config:) end end - Result.new( + ScriptBase::Result.new( subtask: 'uuid-convert', uuids: identities.map { |u| u.user.uuid }, table:, @@ -238,7 +132,7 @@ def run(args:, config:) end end - Result.new( + ScriptBase::Result.new( subtask: 'email-lookup', uuids: users.map(&:uuid), table:, @@ -259,7 +153,7 @@ def run(args:, config:) DataRequests::Deployed::CreateUserReport.new(user, config.requesting_issuers).call end - Result.new( + ScriptBase::Result.new( subtask: 'ig-request', uuids: users.map(&:uuid), json: output, @@ -310,7 +204,7 @@ def run(args:, config:) end end - Result.new( + ScriptBase::Result.new( subtask: 'profile-summary', uuids: users.map(&:uuid), table:, diff --git a/lib/script_base.rb b/lib/script_base.rb new file mode 100644 index 00000000000..51978b0c058 --- /dev/null +++ b/lib/script_base.rb @@ -0,0 +1,133 @@ +require 'optparse' + +class ScriptBase + attr_reader :argv, :stdout, :stderr, :subtask_class, :banner + + def initialize(argv:, stdout:, stderr:, subtask_class:, banner:) + @argv = argv + @stdout = stdout + @stderr = stderr + @subtask_class = subtask_class + @banner = banner + end + + Result = Struct.new( + :table, # tabular output, rendered as an ASCII table or as CSV + :json, # output that should only be formatted as JSON + :subtask, # name of subtask, used for audit logging + :uuids, # Array of UUIDs entered or returned, used for audit logging + keyword_init: true, + ) + + Config = Struct.new( + :include_missing, + :format, + :show_help, + :requesting_issuers, + keyword_init: true, + ) do + alias_method :include_missing?, :include_missing + alias_method :show_help?, :show_help + end + + def config + @config ||= Config.new( + include_missing: true, + format: :table, + show_help: false, + requesting_issuers: [], + ) + end + + def run + option_parser.parse!(argv) + + if config.show_help? || !subtask_class + stderr.puts '*Task*: `help`' + stderr.puts '*UUIDs*: N/A' + + stdout.puts option_parser + return + end + + result = subtask_class.new.run(args: argv, config:) + + stderr.puts "*Task*: `#{result.subtask}`" + stderr.puts "*UUIDs*: #{result.uuids.map { |uuid| "`#{uuid}`" }.join(', ')}" + + if result.json + stdout.puts result.json.to_json + else + render_output(result.table) + end + end + + def option_parser + @option_parser ||= OptionParser.new do |opts| + opts.banner = banner + + opts.on('-i=ISSUER', '--requesting-issuer=ISSUER', <<-MSG) do |issuer| + requesting issuer (used for ig-request task) + MSG + config.requesting_issuers << issuer + end + + opts.on('--help') do + config.show_help = true + end + + opts.on('--csv') do + config.format = :csv + end + + opts.on('--table', 'Output format as an ASCII table (default)') do + config.format = :table + end + + opts.on('--json') do + config.format = :json + end + + opts.on('--[no-]include-missing', <<~STR) do |include_missing| + Whether or not to add rows in the output for missing inputs, defaults to on + STR + config.include_missing = include_missing + end + end + end + + # @param [Array>] rows + def render_output(rows) + return if rows.blank? + + case config.format + when :table + require 'terminal-table' + table = Terminal::Table.new + header, *body = rows + table << header + table << :separator + body.each do |row| + table << row + end + stdout.puts table + when :csv + require 'csv' + CSV.instance(stdout) do |csv| + rows.each do |row| + csv << row + end + end + when :json + headers, *body = rows + + objects = body.map do |values| + headers.zip(values).to_h + end + + stdout.puts JSON.pretty_generate(objects) + else + raise "Unknown format=#{config.format}" + end + end +end diff --git a/lib/tasks/review_profile.rake b/lib/tasks/review_profile.rake index 9d888000c42..581e43cb611 100644 --- a/lib/tasks/review_profile.rake +++ b/lib/tasks/review_profile.rake @@ -1,5 +1,6 @@ require 'io/console' - +# Note: This file should be going away soon!. +# Any modifications made here should be updated accordingly in the lib/action_account.rb file. namespace :users do namespace :review do desc 'Pass a user that has a pending review' diff --git a/spec/lib/action_account_spec.rb b/spec/lib/action_account_spec.rb new file mode 100644 index 00000000000..3d1aaf0a0d0 --- /dev/null +++ b/spec/lib/action_account_spec.rb @@ -0,0 +1,166 @@ +require 'rails_helper' +require 'tableparser' +require 'action_account' + +RSpec.describe ActionAccount do + let(:stdout) { StringIO.new } + let(:stderr) { StringIO.new } + let(:argv) { [] } + + subject(:action_account) { ActionAccount.new(argv:, stdout:, stderr:) } + + describe 'command line flags' do + let(:argv) { ['review-pass', user.uuid] } + let(:user) { create(:user) } + + describe '--help' do + before { argv << '--help' } + it 'prints a help message' do + action_account.run + + expect(stdout.string).to include('Options:') + end + + it 'prints help to stderr', aggregate_failures: true do + action_account.run + + expect(stderr.string).to include('*Task*: `help`') + expect(stderr.string).to include('*UUIDs*: N/A') + end + end + + describe '--csv' do + before { argv << '--csv' } + it 'formats output as CSV' do + action_account.run + + expect(CSV.parse(stdout.string)).to eq( + [ + ['uuid', 'status'], + [user.uuid, 'Error: User does not have a pending fraud review'], + ], + ) + end + end + + describe '--table' do + before { argv << '--table' } + it 'formats output as an ASCII table' do + action_account.run + + expect(Tableparser.parse(stdout.string)).to eq( + [ + ['uuid', 'status'], + [user.uuid, 'Error: User does not have a pending fraud review'], + ], + ) + end + end + + it 'logs UUIDs and the command name to STDERR formatted for Slack', aggregate_failures: true do + action_account.run + + expect(stderr.string).to include('`review-pass`') + expect(stderr.string).to include("`#{user.uuid}`") + end + + describe '--json' do + before { argv << '--json' } + it 'formats output as JSON' do + action_account.run + + expect(JSON.parse(stdout.string)).to eq( + [ + { + 'uuid' => user.uuid, + 'status' => 'Error: User does not have a pending fraud review', + }, + ], + ) + end + end + + describe '--include-missing' do + let(:argv) { ['review-reject', 'does_not_exist@example.com', '--include-missing', '--json'] } + it 'adds rows for missing values' do + action_account.run + + expect(JSON.parse(stdout.string)).to eq( + [ + { + 'uuid' => 'does_not_exist@example.com', + 'status' => 'Error: Could not find user with that UUID', + }, + ], + ) + end + end + + describe '--no-include-missing' do + let(:argv) do + ['review-reject', 'does_not_exist@example.com', '--no-include-missing', '--json'] + end + it 'does not add rows for missing values' do + action_account.run + + expect(JSON.parse(stdout.string)).to be_empty + end + end + end + + describe ActionAccount::ReviewReject do + subject(:subtask) { ActionAccount::ReviewReject.new } + + describe '#run' do + let(:user) { create(:profile, :fraud_review_pending).user } + let(:user_without_profile) { create(:user) } + + let(:args) { [user.uuid, user_without_profile.uuid, 'uuid-does-not-exist'] } + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:) } + subject(:result) { subtask.run(args:, config:) } + + it 'Reject a user that has a pending review', aggregate_failures: true do + expect(result.table).to match_array( + [ + ['uuid', 'status'], + [user.uuid, "User's profile has been deactivated due to fraud rejection."], + [user_without_profile.uuid, 'Error: User does not have a pending fraud review'], + ['uuid-does-not-exist', 'Error: Could not find user with that UUID'], + ], + ) + + expect(result.subtask).to eq('review-reject') + expect(result.uuids).to match_array([user.uuid, user_without_profile.uuid]) + end + end + end + + describe ActionAccount::ReviewPass do + subject(:subtask) { ActionAccount::ReviewPass.new } + + describe '#run' do + let(:user) { create(:profile, :fraud_review_pending).user } + let(:user_without_profile) { create(:user) } + + let(:args) { [user.uuid, user_without_profile.uuid, 'uuid-does-not-exist'] } + let(:include_missing) { true } + let(:config) { ScriptBase::Config.new(include_missing:) } + subject(:result) { subtask.run(args:, config:) } + + it 'Pass a user that has a pending review', aggregate_failures: true do + expect(result.table).to match_array( + [ + ['uuid', 'status'], + [user.uuid, "User's profile has been activated and the user has been emailed."], + [user_without_profile.uuid, 'Error: User does not have a pending fraud review'], + ['uuid-does-not-exist', 'Error: Could not find user with that UUID'], + ], + ) + + expect(result.subtask).to eq('review-pass') + expect(result.uuids).to match_array([user.uuid, user_without_profile.uuid]) + end + end + end +end diff --git a/spec/lib/data_pull_spec.rb b/spec/lib/data_pull_spec.rb index 5ffbe3747f3..a51b32a881e 100644 --- a/spec/lib/data_pull_spec.rb +++ b/spec/lib/data_pull_spec.rb @@ -137,7 +137,7 @@ let(:args) { [*users.map { |u| u.email_addresses.first.email }, 'missing@example.com'] } let(:include_missing) { true } - let(:config) { DataPull::Config.new(include_missing:) } + let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } @@ -164,7 +164,7 @@ let(:args) { [*agency_identities.map(&:uuid), 'does-not-exist'] } let(:include_missing) { true } - let(:config) { DataPull::Config.new(include_missing:) } + let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } it 'converts the agency agency identities to internal UUIDs', aggregate_failures: true do @@ -190,7 +190,7 @@ let(:args) { [user.uuid, 'does-not-exist'] } let(:include_missing) { true } - let(:config) { DataPull::Config.new(include_missing:) } + let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } it 'loads email addresses for the user', aggregate_failures: true do @@ -218,7 +218,7 @@ let(:service_provider) { create(:service_provider) } let(:identity) { IdentityLinker.new(user, service_provider).link_identity } let(:args) { [user.uuid] } - let(:config) { DataPull::Config.new(requesting_issuers: [service_provider.issuer]) } + let(:config) { ScriptBase::Config.new(requesting_issuers: [service_provider.issuer]) } subject(:result) { subtask.run(args:, config:) } @@ -248,7 +248,7 @@ let(:args) { [user.uuid, user_without_profile.uuid, 'uuid-does-not-exist'] } let(:include_missing) { true } - let(:config) { DataPull::Config.new(include_missing:) } + let(:config) { ScriptBase::Config.new(include_missing:) } subject(:result) { subtask.run(args:, config:) } it 'loads profile summary for the user', aggregate_failures: true do