diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 90f0723b3..0011acdc8 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -58,6 +58,7 @@ @import "components/tabs"; @import "components/ct_widget"; @import "components/rack_show_page"; +@import "components/new_team_role_form"; /* Components/Widgets diff --git a/app/assets/stylesheets/components/_actions_dropdown.scss b/app/assets/stylesheets/components/_actions_dropdown.scss index 440c12846..0e7189ba4 100644 --- a/app/assets/stylesheets/components/_actions_dropdown.scss +++ b/app/assets/stylesheets/components/_actions_dropdown.scss @@ -42,6 +42,16 @@ width: 1rem; } } + + .disabled-action { + color: grey; + cursor: not-allowed; + + &:hover { + color: grey; + background-color: white; + } + } &.current a:before { @include icon-style; diff --git a/app/assets/stylesheets/components/_cluster_types_view.scss b/app/assets/stylesheets/components/_cluster_types_view.scss index 3cc760cbc..fad76f31d 100644 --- a/app/assets/stylesheets/components/_cluster_types_view.scss +++ b/app/assets/stylesheets/components/_cluster_types_view.scss @@ -7,16 +7,12 @@ padding: 1.25rem 2.5rem; } - .cluster-type-card { border: solid #3b5168 1px; border-radius: 2px; padding: 1.5rem; font-weight: 400; transition: background 125ms; -} - -.cluster-type-card { display: grid; grid-template-rows: [row1-start] auto [row1-end row2-start] auto [row2-end row3-start] auto [row3-end]; grid-template-columns: 1fr 1fr; @@ -29,6 +25,10 @@ margin: 0 !important; width: 100%; min-height: 14.2rem; + + &.disabled-cluster-type-card { + cursor: not-allowed; + } } .cluster-type-card-title { @@ -88,14 +88,29 @@ margin-bottom: 0.625rem; .cluster-type-card-title { - justify-self: left; - align-self: end; - max-width: 28em; + justify-self: left; + align-self: end; + max-width: 28em; } .cluster-type-card-description { - justify-self: left; - align-self: start; - max-width: 28em; + justify-self: left; + align-self: start; + max-width: 28em; + } +} + +.choose-team { + display: flex; + justify-content: space-around; + margin-bottom: 1rem; + margin-top: 3rem; + + .formItem { + width: 22rem; + } + + select { + margin-bottom: 0; } } diff --git a/app/assets/stylesheets/components/_new_cluster_form.scss b/app/assets/stylesheets/components/_new_cluster_form.scss index 29ddaee89..2457cac3a 100644 --- a/app/assets/stylesheets/components/_new_cluster_form.scss +++ b/app/assets/stylesheets/components/_new_cluster_form.scss @@ -1,7 +1,7 @@ -#new_cluster { +.new_cluster { border: none; .formItem { - margin-bottom: 1rem; + margin-bottom: 1rem; } .new-cluster-field { diff --git a/app/assets/stylesheets/components/_new_team_role_form.scss b/app/assets/stylesheets/components/_new_team_role_form.scss new file mode 100644 index 000000000..ce38e09ed --- /dev/null +++ b/app/assets/stylesheets/components/_new_team_role_form.scss @@ -0,0 +1,5 @@ +#team-roles-table { + .resource_table { + max-width: 45.75rem; + } +} diff --git a/app/assets/stylesheets/irv/_interactive_canvas_view.scss b/app/assets/stylesheets/irv/_interactive_canvas_view.scss index 9f0df6974..4ad0c4752 100644 --- a/app/assets/stylesheets/irv/_interactive_canvas_view.scss +++ b/app/assets/stylesheets/irv/_interactive_canvas_view.scss @@ -157,6 +157,10 @@ -moz-box-shadow: 3px 3px 12px 3px rgba(0,0,0,0.3); -webkit-box-shadow: 3px 3px 12px 3px rgba(0,0,0,0.3); box-shadow: 3px 3px 12px 3px rgba(0,0,0,0.3); + + .menu-spacer:last-child { + display: none; /* Hide spacer if no subsequent options */ + } } .disabled_context_menu_item diff --git a/app/cells/actions_cell.rb b/app/cells/actions_cell.rb index 84b461217..1d93e40cd 100644 --- a/app/cells/actions_cell.rb +++ b/app/cells/actions_cell.rb @@ -17,8 +17,8 @@ class ActionsCell < Cell::ViewModel attr_reader :actions, :dropdown_id - def show(title, block, opts = {}) - # @title = title + def show(text, block, opts = {}) + # @text = text @is_dropdown = opts[:is_dropdown] || false @dropdown_id = opts[:dropdown_id] || 'drop' @side = opts[:side] || false @@ -73,11 +73,11 @@ def initialize(current_user, cell) @current_user = current_user @cell = cell end - + # # add # - # Adds an action based on options hash. Valid options are: title, path, html, side + # Adds an action based on options hash. Valid options are: text, path, html, side # # 'side' == true will render to the sidebar only. # @@ -85,32 +85,32 @@ def initialize(current_user, cell) # # e.g: # - # add(title: 'View', path: user_path(@user)) + # add(text: 'View', path: user_path(@user)) # # =>
  • View
  • # - def add(title_or_options, path = nil, &block) - if title_or_options.kind_of? String + def add(text_or_options, path = nil, &block) + if text_or_options.kind_of? String options = {} - title = title_or_options + text = text_or_options else - options = title_or_options - title = options[:title] + options = text_or_options + text = options[:text] path = options[:path] end html = (block_given? ? block.call : options[:html]) - opts = options.reject {|k, v| [:title, :html, :path, :can, :cannot, :on].include?(k)} + opts = options.reject {|k, v| [:text, :html, :path, :can, :cannot, :on].include?(k)} if html add_custom(html, opts) else - add_item(title, path, opts) + add_item(text, path, opts) end end def add_with_auth(options, &block) resource_or_class = options[:on] - + if options.has_key? :cannot action_name = options[:cannot] permission = :cannot? @@ -118,10 +118,10 @@ def add_with_auth(options, &block) action_name = options[:can] permission = :can? end - + opts = options.reject {|k, v| [:can, :cannot, :on].include?(k)} ability = @current_user.ability - + if ability.send(permission, action_name, resource_or_class) if block_given? add(opts, &block) @@ -130,32 +130,32 @@ def add_with_auth(options, &block) end end end - + # # divider # - # Adds a label with a title that separates menu items. + # Adds a label with content that separates menu items. # - def divider(title = nil) + def divider(text = nil) unless @dropdown_actions.empty? - divider = ActionDivider.new(title) + divider = ActionDivider.new(text) @dropdown_actions << divider end end - + private def side? @cell.side? end - + # # add_item # # Adds a basic link action # - def add_item(title, path, opts = {}) - action = Action.new(title, path, opts, @cell) + def add_item(text, path, opts = {}) + action = Action.new(text, path, opts, @cell) if side? @actions << action else @@ -178,12 +178,12 @@ def add_custom(html, opts = {}) end end end - + class ActionItem def li_opts @opts.reject {|k, v| [:path, :side, :method, :confirm].include?(k) } end - + def matches_request?(request) request.fullpath == @path unless @opts[:method].to_s == 'delete' end @@ -194,24 +194,24 @@ def divider? end class Action < ActionItem - attr_reader :title, :path, :opts - def initialize(title, path, opts = {}, cell) - @title = title + attr_reader :text, :path, :opts + def initialize(text, path, opts = {}, cell) + @text = text @path = path @opts = opts @cell = cell end def html - @cell.link_to @title, @path, @opts + @cell.link_to @text, @path, @opts end end class ActionDivider < ActionItem - attr_reader :title + attr_reader :text - def initialize(title) - @title = title + def initialize(text) + @text = text @path = nil @opts = {} end diff --git a/app/cells/cluster_form_errors/show.erb b/app/cells/cluster_form_errors/show.erb index 7735acb3d..091d6c9a1 100644 --- a/app/cells/cluster_form_errors/show.erb +++ b/app/cells/cluster_form_errors/show.erb @@ -1,5 +1,11 @@ -<% if has_errors? %> +<% if has_errors_without_input_field? %>
    - Please correct the <%= "error".pluralize(error_count) %> below and try again. + Unable to launch cluster: <%= inputless_errors_text %> +
    +<% end %> + +<% if has_input_field_errors? %> +
    + Please correct the <%= "error".pluralize(input_field_error_count) %> below and try again.
    <% end %> diff --git a/app/cells/cluster_form_errors_cell.rb b/app/cells/cluster_form_errors_cell.rb index 0e31ecdd3..27edb742b 100644 --- a/app/cells/cluster_form_errors_cell.rb +++ b/app/cells/cluster_form_errors_cell.rb @@ -6,25 +6,44 @@ def show(cluster) private - def has_errors? - error_count > 0 + def attributes_without_input_field + [:team] end - def error_count + def has_input_field_errors? + input_field_error_count > 0 + end + + def input_field_error_count if @cluster.errors.any? # If the cluster has any errors set against it, it is expected that these # will contain any field errors too. - @cluster.errors.count + @cluster.errors.count - inputless_error_count elsif @cluster.fields.any? { |f| !f.errors.empty? } # If the cluster does not have any errors, it is still possible that the # fields do. These can be set from the cluster builder response. @cluster.fields - .select { |f| !f.errors.empty? } - .map { |f| f.errors.count } - .sum + .select { |f| !f.errors.empty? } + .map { |f| f.errors.count } + .sum else 0 end end + + def has_errors_without_input_field? + @cluster.errors.any? && attributes_without_input_field.any? { |attribute| !@cluster.errors[attribute].empty? } + end + + def inputless_error_count + attributes_without_input_field.select { |attribute| !@cluster.errors[attribute].empty? }.length + end + + def inputless_errors_text + @cluster.errors.select { |error| attributes_without_input_field.include?(error.attribute) } + .map(&:full_message) + .join("; ") + + end end diff --git a/app/cells/resource_table_cell.rb b/app/cells/resource_table_cell.rb index 3cbdd9902..292f637c7 100644 --- a/app/cells/resource_table_cell.rb +++ b/app/cells/resource_table_cell.rb @@ -82,7 +82,7 @@ def on_empty_collection(&block) @table.empty_collection_block = block end - private + private def add_column(column, opts) return if opts[:suppress_if] @@ -99,9 +99,9 @@ def add_column(column, opts) # # ResourceTable # - # A model representing the actual table itself. + # A model representing the actual table itself. # - # Attributes of note: + # Attributes of note: # # => @id The html id of the table # => @items The items being rendered by the table @@ -129,7 +129,7 @@ def initialize(id, items, controller, opts = {}) establish_if_paginatable end - + def empty? @items.empty? end @@ -180,8 +180,8 @@ def establish_if_paginatable # # Column - # - # Base class for all types of column that could be added to the table. + # + # Base class for all types of column that could be added to the table. # class Column include ActionView::Helpers::TagHelper @@ -214,7 +214,7 @@ def html_class(index = nil) # render_content_for # # Simple accessor for the row/column's content. This *may* become more complex over time, for - # example if someone asks for all dates in tables to look a certain way, this is where you + # example if someone asks for all dates in tables to look a certain way, this is where you # would make this change in order to keep that logic out of the view. # def render_content_for(item) @@ -223,7 +223,7 @@ def render_content_for(item) def sortable? @opts[:sortable] == true - end + end # If this table is sortable, this yields the data required to render the sortable header that the # user clicks on. The `yield`ed values are: @@ -235,9 +235,9 @@ def sortable? def sortable_header(current_sort_column, current_sort_direction) sort_expression = sort_column.to_s is_current = sort_expression == current_sort_column - sort_order = (is_current && current_sort_direction == "asc") ? "desc" : "asc" + sort_order = (is_current && current_sort_direction == "asc") ? "desc" : "asc" - yield sort_expression, sort_order, is_current + yield sort_expression, sort_order, is_current end # Will either be the method name (in the case of attribute columns) or will be @@ -254,7 +254,7 @@ def select_all_column? # # CustomColumn - # + # # This is a basic column type that just yields the item back to the view. # class CustomColumn < Column @@ -275,7 +275,7 @@ def render_content_for(item) # # AttributeColumn - # + # # This column type will call a given method on the item, and then yield the # result of that method as well as the item back to the view. # @@ -291,7 +291,7 @@ def render_content_for(item) if @block @block.call item, item.send(@method) else - item.send(@method) + item.send(@method) end rescue Exception => e raise "Tried to call method '#{@method}' on #{item}: #{e.message}" @@ -303,7 +303,7 @@ def render_content_for(item) # SelectAllColumn # # Renders a "select all" column to the view, yielding the item. - # + # class SelectAllColumn < Column def initialize(opts, &block) diff --git a/app/controllers/api/v1/irv/devices_controller.rb b/app/controllers/api/v1/irv/devices_controller.rb index 53621c2c7..9d084cff3 100644 --- a/app/controllers/api/v1/irv/devices_controller.rb +++ b/app/controllers/api/v1/irv/devices_controller.rb @@ -30,13 +30,6 @@ def request_status_change return end - unless current_user.project_id || current_user.root? - render json: { - success: false, errors: ["You do not yet have a project id. This will be added automatically shortly"] - }, status: 403 - return - end - action = params["task"] # action is already used as a param by rails unless @device.valid_action?(action) render json: { success: false, errors: ["cannot perform action '#{action}' on this device"]}, status: 400 diff --git a/app/controllers/api/v1/irv/racks_controller.rb b/app/controllers/api/v1/irv/racks_controller.rb index be3d453ca..a62e8eac4 100644 --- a/app/controllers/api/v1/irv/racks_controller.rb +++ b/app/controllers/api/v1/irv/racks_controller.rb @@ -30,13 +30,6 @@ def request_status_change return end - unless current_user.project_id || current_user.root? - render json: { - success: false, errors: ["You do not yet have a project id. This will be added automatically shortly"] - }, status: 403 - return - end - action = params["task"] # action is already used as a param by rails unless @rack.valid_action?(action) render json: { success: false, errors: ["cannot perform action '#{action}' on this rack"]}, status: 400 diff --git a/app/controllers/api/v1/racks_controller.rb b/app/controllers/api/v1/racks_controller.rb index 3318d6d47..a51689b3b 100644 --- a/app/controllers/api/v1/racks_controller.rb +++ b/app/controllers/api/v1/racks_controller.rb @@ -13,9 +13,9 @@ def show end def create - @rack = HwRackServices::Create.call(rack_params.to_h, current_user) + @rack = HwRack.new(rack_params) - if @rack.persisted? + if @rack.save @rack = Api::V1::RackPresenter.new(@rack) render action: :show else @@ -51,7 +51,7 @@ def destroy def rack_params permitted = PERMITTED_PARAMS.dup.tap do |a| a << :order_id if current_user.root? - a << :user_id if current_user.root? && params[:action] == 'create' + a << :team_id if params[:action] == 'create' end params.fetch(:rack).permit(*permitted) end diff --git a/app/controllers/api/v1/teams_controller.rb b/app/controllers/api/v1/teams_controller.rb new file mode 100644 index 000000000..d70a4161f --- /dev/null +++ b/app/controllers/api/v1/teams_controller.rb @@ -0,0 +1,44 @@ +class Api::V1::TeamsController < Api::V1::ApplicationController + load_and_authorize_resource :team + + def index + @teams = @teams.map { |team| Api::V1::TeamPresenter.new(team) } + end + + def show + @team = Api::V1::TeamPresenter.new(@team) + render + end + + def update + if @team.update(team_params) + @team = Api::V1::TeamPresenter.new(@team) + render action: :show + else + render json: @team.errors.as_json, status: :unprocessable_entity + end + end + + def destroy + if !@team.racks.empty? && !ActiveModel::Type::Boolean.new.cast(params[:recurse]) + error = {status: "422", title: "Unprocessable Content", description: "Cannot delete team as they have active racks"} + render json: {errors: [error]}, status: :unprocessable_entity + elsif TeamServices::Delete.call(@team) + render json: {}, status: :ok + else + render json: @team.errors.as_json, status: :unprocessable_entity + end + end + + private + + def team_params + permitted_params = + if current_user.root? + [:project_id, :cost, :credits, :billing_acct_id, :billing_period_start, :billing_period_end] + else + [] + end + params.require(:team).permit(*permitted_params) + end +end diff --git a/app/controllers/api/v1/users_controller.rb b/app/controllers/api/v1/users_controller.rb index da85c5701..20687aeb2 100644 --- a/app/controllers/api/v1/users_controller.rb +++ b/app/controllers/api/v1/users_controller.rb @@ -1,5 +1,5 @@ class Api::V1::UsersController < Api::V1::ApplicationController - load_and_authorize_resource :user, :class => User, except: [:current, :can_i?] + load_and_authorize_resource :user, :class => User, except: [:current, :permissions] def index @users = @users.map {|user| Api::V1::UserPresenter.new(user)} @@ -38,36 +38,26 @@ def destroy end # - # GET /api/v1/users/can_i + # GET /api/v1/users/permissions # - # Endpoint for cancan check - this just passes the "can" request on to the - # cancan ability checker - used to check yourself and your own abilities. + # Endpoint for specifying what permissions each team role/ being root provides. + # This is based on the assumption that such permissions are based purely + # on team role for the given object (or being root). # - def can_i? - # On the permissions params, this action should receive a structure of the - # following form - # - # { - # "permissions" => { - # "manage" => {"0" => "HwRack", "1" => "Device"}, - # "read" => {"0" => "Device"}, - # "move" => {"0" => "Device"}, - # } - # } - - result = {} - params[:permissions].each do |rbac_action,rbac_resources| - result[rbac_action] = {} - rbac_resources.each do |_, rbac_resource| - if rbac_resource == "all" - result[rbac_action][rbac_resource] = current_user.can?(rbac_action.to_sym, :all) - elsif rbac_resource.safe_constantize - result[rbac_action][rbac_resource] = current_user.can?(rbac_action.to_sym, rbac_resource.safe_constantize) - else - result[rbac_action][rbac_resource] = false - end - end - end + def permissions + admins = %w(superAdmin admin) + all = admins + ["member"] + result = { + manage: { + racks: admins, devices: admins, chassis: admins + }, + move: { + racks: [], devices: admins, chassis: admins + }, + view: { + racks: all, devices: all, chassis: all + } + } render json: result end diff --git a/app/controllers/cluster_types_controller.rb b/app/controllers/cluster_types_controller.rb index 091cd0523..de548d090 100644 --- a/app/controllers/cluster_types_controller.rb +++ b/app/controllers/cluster_types_controller.rb @@ -9,5 +9,9 @@ def index flash.now.alert = result.error_message unless result.success? end @cluster_types = @cluster_types.reorder(:order, :id) + @valid_teams = current_user.teams_where_admin.meets_cluster_credit_requirement + @unavailable_teams = current_user.teams.where.not(id: @valid_teams.pluck(:id)) + @all_teams = current_user.teams.reorder(:name) + @team = Team.find(params[:team_id]) if params[:team_id] end end diff --git a/app/controllers/clusters_controller.rb b/app/controllers/clusters_controller.rb index 38de864df..80ef965f2 100644 --- a/app/controllers/clusters_controller.rb +++ b/app/controllers/clusters_controller.rb @@ -9,6 +9,7 @@ def new end @cluster_type = ClusterType.find_by_foreign_id!(params[:cluster_type_foreign_id]) + @team = current_user.teams.find(params[:team_id]) use_cache = params[:use_cache] != "false" result = SyncIndividualClusterTypeJob.perform_now(@cloud_service_config, @cluster_type, use_cache) unless result.success? @@ -17,29 +18,32 @@ def new return end set_cloud_assets - @cluster = Cluster.new(cluster_type: @cluster_type) + @cluster = Cluster.new(cluster_type: @cluster_type, team: @team) end def create - authorize! :create, Cluster @cloud_service_config = CloudServiceConfig.first @cluster_type = ClusterType.find_by_foreign_id!(params[:cluster_type_foreign_id]) + @team = Team.find(permitted_params[:team_id]) selections = (permitted_params[:selections] || {}).transform_values { |v| ActiveModel::Type::Boolean.new.cast(v) }.to_h @cluster = Cluster.new( cluster_type: @cluster_type, name: permitted_params[:name], cluster_params: permitted_params[:cluster_params], selections: selections, + team: @team ) + authorize! :create, @cluster + if @cloud_service_config.nil? flash.now.alert = "Unable to send cluster configuration: cloud environment config not set. Please contact an admin" render action: :new return end - unless current_user.project_id - flash.now.alert = "Unable to send cluster configuration: you do not yet have a project id. " \ + unless @team.project_id + flash.now.alert = "Unable to send cluster configuration: your team does not yet have a project id. " \ "This will be added automatically shortly." render action: :new return @@ -75,7 +79,7 @@ def permitted_params valid_selections = @cluster_type.field_groups .select { |group| group["optional"].present? } .map { |group| group["optional"]["name"] } - params.require(:cluster).permit(:name, cluster_params: @cluster_type.fields.keys, selections: valid_selections).tap do |h| + params.require(:cluster).permit(:name, :team_id, cluster_params: @cluster_type.fields.keys, selections: valid_selections).tap do |h| if !h.key?(:name) && h[:cluster_params].key?(Cluster::NAME_FIELD.to_sym) h[:name] = h[:cluster_params][Cluster::NAME_FIELD.to_sym] end @@ -83,7 +87,7 @@ def permitted_params end def set_cloud_assets - result = GetCloudAssetsJob.perform_now(@cloud_service_config, current_user) + result = GetCloudAssetsJob.perform_now(@cloud_service_config, current_user, @team) if result.success? @cloud_assets = result.assets else diff --git a/app/controllers/credit_deposits_controller.rb b/app/controllers/credit_deposits_controller.rb index 1da77457d..3f617e221 100644 --- a/app/controllers/credit_deposits_controller.rb +++ b/app/controllers/credit_deposits_controller.rb @@ -1,32 +1,41 @@ class CreditDepositsController < ApplicationController def new - @user = User.find(params[:id]) - @credit_deposit = CreditDeposit.new(user: @user) + @team = Team.find(params[:team_id]) + @credit_deposit = CreditDeposit.new(team: @team) authorize! :create, @credit_deposit @cloud_service_config = CloudServiceConfig.first - check_config_and_external_ids + if @cloud_service_config.nil? + flash[:alert] = "Unable to add credits: cloud environment config not set" + redirect_to teams_path + elsif !@credit_deposit.valid? + flash[:alert] = "Unable to add credits: #{@credit_deposit.errors.full_messages.join("; ")}" + redirect_to teams_path + end end def create - @user = User.find(params[:id]) + @team = Team.find(params[:team_id]) @cloud_service_config = CloudServiceConfig.first - @credit_deposit = CreditDeposit.new(user: @user, amount: credit_deposit_params[:amount]) + @credit_deposit = CreditDeposit.new(team: @team, amount: credit_deposit_params[:amount]) authorize! :create, @credit_deposit - if check_config_and_external_ids - unless @credit_deposit.valid? - render action: :new - return - end + if @cloud_service_config.nil? + flash[:alert] = "Unable to add credits: cloud environment config not set" + redirect_to teams_path + return + elsif !@credit_deposit.valid? + flash.now[:alert] = "Unable to add credits: #{@credit_deposit.errors.full_messages.join("; ")}" + render :new + return + end - result = CreateCreditDepositJob.perform_now(@credit_deposit, @cloud_service_config, @user) - if result.success? - flash[:success] = "Credit deposit submitted for #{@user.name}. It may take a few minutes for the user's new balance to be reflected." - redirect_to users_path - else - flash[:alert] = "Unable to submit credit deposit: #{result.error_message}" - render :new - end + result = CreateCreditDepositJob.perform_now(@credit_deposit, @cloud_service_config) + if result.success? + flash[:success] = "Credit deposit submitted for #{@team.name}. It may take a few minutes for the team's new balance to be reflected." + redirect_to teams_path + else + flash.now[:alert] = "Unable to submit credit deposit: #{result.error_message}" + render :new end end @@ -36,27 +45,4 @@ def create def credit_deposit_params params.require(:credit_deposit).permit(*PERMITTED_PARAMS) end - - def check_config_and_external_ids - redirect = false - if @cloud_service_config.nil? - flash[:alert] = "Unable to add credits: cloud environment config not set" - redirect = true - elsif @user.project_id.nil? - flash[:alert] = "Unable to add credits: user does not yet have a project id. " \ - "This should be added automatically shortly." - redirect = true - elsif @user.billing_acct_id.nil? - flash[:alert] = "Unable to add credits: user does not yet have a billing account id. " \ - "This should be added automatically shortly." - redirect = true - end - - if redirect - redirect_to users_path - false - else - true - end - end end diff --git a/app/controllers/invoices_controller.rb b/app/controllers/invoices_controller.rb index f506133ef..ba4589bcb 100644 --- a/app/controllers/invoices_controller.rb +++ b/app/controllers/invoices_controller.rb @@ -3,14 +3,14 @@ class InvoicesController < ApplicationController include ControllerConcerns::Pagination - before_action :redirect_if_root + before_action :set_team before_action :ensure_cloud_service_configured before_action :ensure_billing_account_configured def index - authorize! :index, Invoice + authorize! :read, Invoice.new(account: @team) @pagy = Pagy::DelayedCount.new(pagy_get_vars_without_count) - result = GetInvoicesJob.perform_now(@cloud_service_config, current_user, offset: @pagy.offset, limit: @pagy.items) + result = GetInvoicesJob.perform_now(@cloud_service_config, @team, offset: @pagy.offset, limit: @pagy.items) if result.success? @pagy.finalize(result.invoices_count) @invoices = result.invoices @@ -22,36 +22,33 @@ def index end def show - result = GetInvoiceJob.perform_now(@cloud_service_config, current_user, params[:id]) + result = GetInvoiceJob.perform_now(@cloud_service_config, @team, params[:id]) if result.success? @invoice = result.invoice authorize! :show, @invoice render else flash[:alert] = "Unable to fetch invoice: #{result.error_message}" - redirect_to invoices_path + redirect_to team_invoices_path(@team) end end def draft - result = GetDraftInvoiceJob.perform_now(@cloud_service_config, current_user) + result = GetDraftInvoiceJob.perform_now(@cloud_service_config, @team) if result.success? @invoice = result.invoice authorize! :show, @invoice render action: :show else flash[:alert] = "Unable to fetch draft invoice: #{result.error_message}" - redirect_to invoices_path + redirect_to team_invoices_path(@team) end end private - def redirect_if_root - if current_user.root? - flash[:alert] = "Unable to fetch invoice for admin user" - redirect_back_or_to root_path - end + def set_team + @team = Team.find(params[:team_id]) end def ensure_cloud_service_configured @@ -64,8 +61,8 @@ def ensure_cloud_service_configured end def ensure_billing_account_configured - if current_user.billing_acct_id.blank? - flash.now[:alert] = "Unable to fetch invoices. You do not yet have a " \ + if @team.billing_acct_id.blank? + flash.now[:alert] = "Unable to fetch invoices. The team does not yet have a " \ "billing account id. This will be added automatically shortly." render action: :index end diff --git a/app/controllers/key_pairs_controller.rb b/app/controllers/key_pairs_controller.rb index 560a9c1d3..031cfeb9f 100644 --- a/app/controllers/key_pairs_controller.rb +++ b/app/controllers/key_pairs_controller.rb @@ -1,14 +1,15 @@ class KeyPairsController < ApplicationController + before_action :set_project_id, except: :new + def new - authorize! :create, KeyPair @user = current_user + @key_pair = KeyPair.new(user: @user) + authorize! :create, @key_pair @cloud_service_config = CloudServiceConfig.first if @cloud_service_config.nil? flash[:alert] = "Unable to create key-pairs: cloud environment config not set" redirect_to root_path - return end - @key_pair = KeyPair.new(user: @user) end def index @@ -19,20 +20,18 @@ def index return end - unless current_user.project_id - flash[:alert] = "Unable to check key-pairs: you do not yet have a project id. " \ - "This will be added automatically shortly." + unless @project_id + flash[:alert] = "Unable to check key-pairs: you must belong to a team with a project id." redirect_to edit_user_registration_path return end - result = GetUserKeyPairsJob.perform_now(@cloud_service_config, current_user) + result = GetUserKeyPairsJob.perform_now(@cloud_service_config, current_user, @project_id) if result.success? @key_pairs = result.key_pairs else flash[:alert] = "Unable to get key-pairs: #{result.error_message}" redirect_to edit_user_registration_path - return end end @@ -40,7 +39,7 @@ def create @cloud_service_config = CloudServiceConfig.first @user = current_user public_key = key_pair_params[:public_key].blank? ? nil : key_pair_params[:public_key] - @key_pair = @key_pair = KeyPair.new(user: @user, name: key_pair_params[:name], key_type: key_pair_params[:key_type], + @key_pair = KeyPair.new(user: @user, name: key_pair_params[:name], key_type: key_pair_params[:key_type], public_key: public_key) authorize! :create, @key_pair @@ -50,14 +49,13 @@ def create return end - unless current_user.project_id - flash[:alert] = "Unable to send key-pair request: you do not yet have a project id. " \ - "This will be added automatically shortly." + unless @project_id + flash[:alert] = "Unable to create key-pair: you must belong to a team with a project id." redirect_to edit_user_registration_path return end - result = CreateKeyPairJob.perform_now(@key_pair, @cloud_service_config, current_user) + result = CreateKeyPairJob.perform_now(@key_pair, @cloud_service_config, current_user, @project_id) if result.success? render action: :success @@ -77,14 +75,13 @@ def destroy return end - unless current_user.project_id - flash[:alert] = "Unable to send key-pair deletion request: you do not yet have a project id. " \ - "This will be added automatically shortly." + unless @project_id + flash[:alert] = "Unable to send key-pair deletion request: you must belong to a team with a project id." redirect_to edit_user_registration_path return end - result = DeleteKeyPairJob.perform_now(params[:name], @cloud_service_config, current_user) + result = DeleteKeyPairJob.perform_now(params[:name], @cloud_service_config, current_user, @project_id) if result.success? flash[:success] = "Key-pair '#{params[:name]}' deleted" @@ -100,4 +97,10 @@ def destroy def key_pair_params params.require(:key_pair).permit(*PERMITTED_PARAMS) end + + # key pairs are user (not project) specific, but membership of a project is required + # to view, create and delete them + def set_project_id + @project_id = current_user.teams.where.not(project_id: nil).where(deleted_at: nil).first&.project_id + end end diff --git a/app/controllers/team_roles_controller.rb b/app/controllers/team_roles_controller.rb new file mode 100644 index 000000000..94d8dc0f9 --- /dev/null +++ b/app/controllers/team_roles_controller.rb @@ -0,0 +1,114 @@ +class TeamRolesController < ApplicationController + include ControllerConcerns::ResourceTable + before_action :set_team, except: [:edit, :update, :destroy] + load_and_authorize_resource :team_role, only: [:edit, :update, :destroy] + + def index + authorize! :manage, @team.team_roles.new + @team_roles = @team.team_roles.accessible_by(current_ability, :read) + @team_roles = resource_table_collection(@team_roles) + end + + def new + @team_role = TeamRole.new(team_id: @team.id) + authorize! :create, @team_role + end + + def create + @team_role = @team.team_roles.new(team_role_params) + authorize! :create, @team_role + + @cloud_service_config = CloudServiceConfig.first + if @cloud_service_config.nil? + flash.now.alert = "Unable to create team role: cloud environment config not set." + render action: :new + return + end + + unless @team_role.user&.cloud_user_id + flash.now[:alert] = "Unable to add user to team: user does not yet have a cloud ID. " \ + "This will be added automatically shortly." + render action: :new + return + end + + unless @team_role.team&.project_id + flash.now[:alert] = "Unable to add user to team: team does not yet have a project id. " \ + "This will be added automatically shortly." + render action: :new + return + end + + unless @team_role.valid? + flash.now[:alert] = "Unable to add user to team." + render action: :new + return + end + + result = CreateTeamRoleJob.perform_now(@team_role, @cloud_service_config) + + if result.success? + flash[:success] = "User added to team" + redirect_to team_team_roles_path(@team) + else + flash.now[:alert] = result.error_message + render action: :new + end + end + + def destroy + @cloud_service_config = CloudServiceConfig.first + if @cloud_service_config.nil? + flash.now.alert = "Unable to remove team role: cloud environment config not set." + redirect_to edit_team_role_path(@team_role) + return + end + + @team = @team_role.team + result = DeleteTeamRoleJob.perform_now(@team_role, @cloud_service_config) + + if result.success? + flash[:success] = "User removed from team" + redirect_to @team_role.user == current_user ? teams_path : team_team_roles_path(@team) + else + flash[:alert] = result.error_message + redirect_to team_team_roles_path(@team) + end + end + + def update + @cloud_service_config = CloudServiceConfig.first + if @cloud_service_config.nil? + flash.now[:alert] = "Unable to update team role: cloud environment config not set." + render action: :edit + return + end + + unless team_role_params[:role] != @team_role.role + flash.now[:warning] = "Role not updated - you have not changed any values" + render action: :edit + return + end + + result = UpdateTeamRoleJob.perform_now(@team_role, team_role_params[:role], @cloud_service_config) + + if result.success? + flash[:info] = "Successfully updated team role" + redirect_to @team_role.user == current_user ? teams_path : team_team_roles_path(@team_role.team) + else + flash[:alert] = "Unable to update team role" + render action: :edit + end + end + + private + + PERMITTED_PARAMS = %w[user_id role] + def team_role_params + params.fetch(:team_role).permit(*PERMITTED_PARAMS) + end + + def set_team + @team = Team.find(params[:team_id]) + end +end diff --git a/app/controllers/teams_controller.rb b/app/controllers/teams_controller.rb new file mode 100644 index 000000000..6d448189d --- /dev/null +++ b/app/controllers/teams_controller.rb @@ -0,0 +1,55 @@ +class TeamsController < ApplicationController + include ControllerConcerns::ResourceTable + load_and_authorize_resource :team, except: :create + + def index + @teams = resource_table_collection(@teams) + end + + def create + @cloud_service_config = CloudServiceConfig.first + @team = Team.new(name: team_params[:name]) + authorize! :create, @team + + if @cloud_service_config.nil? + flash[:alert] = "Unable to create new team: cloud environment config not set." + redirect_to new_team_path + return + end + + if @team.save + CreateTeamJob.perform_later(@team, @cloud_service_config) + flash[:success] = "Team created. Project ID and billing account ID should be added automatically." + redirect_to teams_path + else + flash.now[:alert] = "Unable to create team" + render action: :new + end + end + + def update + if @team.update(team_params) + flash[:info] = "Successfully updated team" + redirect_to teams_path + else + flash[:alert] = "Unable to update team" + render action: :edit + end + end + + def destroy + if TeamServices::Delete.call(@team) + flash[:info] = "Scheduled team for deletion" + else + flash[:alert] = "Unable to schedule team for deletion" + end + redirect_to teams_path + end + + private + + PERMITTED_PARAMS = %w[name project_id billing_acct_id] + def team_params + params.fetch(:team).permit(*PERMITTED_PARAMS) + end +end diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 58c82709e..c1169cebb 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -25,14 +25,14 @@ def destroy flash[:info] = "Scheduled user for deletion" redirect_to users_path else - flash[:alert] = "Unable to scheduled user for deletion" + flash[:alert] = "Unable to schedule user for deletion" redirect_to users_path end end private - PERMITTED_PARAMS = %w[name cloud_user_id project_id billing_acct_id password password_confirmation] + PERMITTED_PARAMS = %w[name cloud_user_id password password_confirmation] def user_params params.fetch(:user).permit(*PERMITTED_PARAMS) end diff --git a/app/javascript/canvas/common/util/RBAC.js b/app/javascript/canvas/common/util/RBAC.js index 58ab80a7b..aeb656986 100644 --- a/app/javascript/canvas/common/util/RBAC.js +++ b/app/javascript/canvas/common/util/RBAC.js @@ -3,12 +3,10 @@ import Events from 'canvas/common/util/Events'; // RBAC = Rule Based Access Control // -// RBAC queries the api action /-/api/v1/users/can_i with a -// specific set of permissions (getPermissionsToQuery) on construction time. +// RBAC queries the api action /-/api/v1/users/permissions on construction time. // Then, via the function can_i, the results obtained from the api call are queried. -// This class is shared between the DCRV and DCPV. class RBAC { - static PATH = '/api/v1/users/can_i'; + static PATH = '/api/v1/users/permissions'; constructor({onSuccess}) { this.onSuccessCallback = onSuccess; @@ -18,7 +16,6 @@ class RBAC { headers : {'X-CSRF-Token': $$('meta[name="csrf-token"]')[0].getAttribute('content')}, url : RBAC.PATH, method : 'get', - data : this.getPermissionsToQuery(), onSuccess : this.permisionsReceived }).send(); } @@ -29,34 +26,23 @@ class RBAC { } permisionsReceived(permissions) { - this.debug("recevied permissions"); + this.debug("received permissions"); this.permissions = permissions; if (this.onSuccessCallback) { this.onSuccessCallback() } } - getPermissionsToQuery() { - return { - permissions: - { - manage: ["HwRack", "Device", "Chassis"], - move: ["Device", "Chassis"], - view: ["all"] - } - }; + can_i(action, resource, teamRole) { + return this.permissions[action][resource].includes(teamRole); } - can_i(action, resource) { - return this.permissions[action][resource] === true; + can_i_move_device(device) { + return this.can_i("move", "devices", device.teamRole) || this.can_i("move", "chassis", device.teamRole); } - can_i_move_devices() { - return this.can_i("move", "Device") || this.can_i("move", "Chassis"); - } - - can_i_manage_devices() { - return this.can_i("manage", "Device") || this.can_i("manage", "Chassis"); + can_i_manage_device(device) { + return this.can_i("manage", "devices", device.teamRole) || this.can_i("manage", "chassis", device.teamRole); } debug(...msg) { diff --git a/app/javascript/canvas/irv/IRVController.js b/app/javascript/canvas/irv/IRVController.js index 8b7e5dee2..12852fb9b 100644 --- a/app/javascript/canvas/irv/IRVController.js +++ b/app/javascript/canvas/irv/IRVController.js @@ -293,9 +293,10 @@ class IRVController { this.cancelDragging(); } const defs = this.parser.parseRackDefs({Racks: data["Racks"]}); + this.assetCount = 0; this.initialiseRackDefs(defs); if (this.initialised) { - this.rackSpace.resetRackSpace(); + this.synchroniseChanges(); } else { this.testLoadProgress(); } @@ -1389,7 +1390,8 @@ class IRVController { this.assetCount = 0; } Array.from(assets).map((asset) => // deal with loading the images - AssetManager.get(IRVController.PRIMARY_IMAGE_PATH + asset, this.evAssetLoaded, this.evAssetFailed)); + AssetManager.get(IRVController.PRIMARY_IMAGE_PATH + asset, this.evAssetLoaded, this.evAssetFailed) + ); } // asset manager callback invoked when an image finishes loading. Tests if all assets have been loaded successfully @@ -1507,7 +1509,7 @@ class IRVController { const num_assets = assets.length; progress = this.calculateProgress(num_assets); this.debug(`load progress: resources:${this.resourceCount}/${IRVController.NUM_RESOURCES} assets:${this.assetCount}/${num_assets} progress:${progress}`); - if ((this.resourceCount === IRVController.NUM_RESOURCES) && (this.assetCount === num_assets)) { + if ((this.resourceCount >= IRVController.NUM_RESOURCES) && (this.assetCount === num_assets)) { // We have loaded everything now, this is where we action any // rebuilding and redrawing this.assetCount = 0; diff --git a/app/javascript/canvas/irv/view/ContextMenu.js b/app/javascript/canvas/irv/view/ContextMenu.js index 52eab0b66..3439338f2 100644 --- a/app/javascript/canvas/irv/view/ContextMenu.js +++ b/app/javascript/canvas/irv/view/ContextMenu.js @@ -100,8 +100,14 @@ class ContextMenu { var view_devices; var idx = parsed.length; parsed.push([]); + let total_options = null; - var total_options = [].concat(ContextMenu.OPTIONS[option_set]); + if(option_set === "devices") { + let typeOptions = ContextMenu.OPTIONS["devices"][device.type] || []; + total_options = [].concat(ContextMenu.OPTIONS["devices"]["common"].concat(typeOptions)); + } else { + total_options = [].concat(ContextMenu.OPTIONS[option_set]); + } if (option_set === "racks") { if (device.children.length > 0) { @@ -112,8 +118,8 @@ class ContextMenu { for (var option of Array.from(total_options)) { // If the option has the attribute RBAC defined, then query the @model.RBAC object // to see if such permission has been granted. Otherwise, continue to the next option. - if (option.RBAC != null) { - if (!this.model.RBAC.can_i(option.RBAC.action,option.RBAC.resource)) { continue; } + if (option.rbacAction != null) { + if (!this.model.RBAC.can_i(option.rbacAction, option_set, device.teamRole)) { continue; } } if (option.availableToBuildStatuses !== undefined && option.availableToBuildStatuses.indexOf(device.buildStatus) === -1) { @@ -125,6 +131,7 @@ class ContextMenu { var option_url = option.url; var disabled = false; var on_click = disabled ? null : option.onClick; + let newTab = option.newTab; piece = Util.substitutePhrase(piece, 'view_devices', view_devices); piece = Util.substitutePhrase(piece, 'device_id', device_id); @@ -185,7 +192,7 @@ class ContextMenu { parsed[idx].push(`
    ${piece}
    `); } else { total_clickable_options_added += 1; - parsed[idx].push(`
    ${piece}
    `); + parsed[idx].push(`
    ${piece}
    `); } } else { parsed[idx].push(piece); diff --git a/app/javascript/canvas/irv/view/Machine.js b/app/javascript/canvas/irv/view/Machine.js index 43fa0fbe7..8f3e3018d 100644 --- a/app/javascript/canvas/irv/view/Machine.js +++ b/app/javascript/canvas/irv/view/Machine.js @@ -65,6 +65,7 @@ class Machine extends RackObject { this.slot_id = null; this.selected = false; this.assets = []; + this.type = def.type; if (RackObject.MODEL.metricLevel !== undefined) { this.metric = new BarMetric(this.componentClassName, this.id, this, this.x, this.y, this.width, this.height, RackObject.MODEL); diff --git a/app/javascript/canvas/irv/view/Rack.js b/app/javascript/canvas/irv/view/Rack.js index 87029b320..ceded8a73 100644 --- a/app/javascript/canvas/irv/view/Rack.js +++ b/app/javascript/canvas/irv/view/Rack.js @@ -123,7 +123,6 @@ class Rack extends RackObject { this.selected = false; this.visible = true; this.assets = []; - this.addImageLink(); const len = def.chassis.length; @@ -307,14 +306,14 @@ class Rack extends RackObject { })(); } - draw(show_u_labels, show_name_label, show_owner_label) { + draw(show_u_labels, show_name_label) { Profiler.begin(Profiler.DEBUG, this.draw); // clear for (var asset of Array.from(this.assets)) { RackObject.RACK_GFX.remove(asset); } this.assets = []; // add labels as necessary - this.showOwnerLabel(show_owner_label); + this.showOwnerLabel(show_name_label); this.showNameLabel(show_name_label); this.showULabels(show_u_labels); diff --git a/app/javascript/canvas/irv/view/RackObject.js b/app/javascript/canvas/irv/view/RackObject.js index 8ad8ef1bc..36e711f32 100644 --- a/app/javascript/canvas/irv/view/RackObject.js +++ b/app/javascript/canvas/irv/view/RackObject.js @@ -57,6 +57,10 @@ class RackObject extends RackSpaceObject { this.parent = ko.observable(this.parent); this.parent.subscribe(this.setLayers); + // Based on the assumption that items in a rack share the same role. + // This will need to change if we have more specific permissions e.g. for individual devices + this.teamRole = def.teamRole || this.parent()?.teamRole || "unknown"; + this.setLayers(); this.availableSpaces = []; diff --git a/app/javascript/canvas/irv/view/RackSpace.js b/app/javascript/canvas/irv/view/RackSpace.js index a48cbe9bc..c08fe6f60 100644 --- a/app/javascript/canvas/irv/view/RackSpace.js +++ b/app/javascript/canvas/irv/view/RackSpace.js @@ -539,9 +539,8 @@ class RackSpace { const show_u_labels = this.scale >= RackSpace.U_LBL_SCALE_CUTOFF; const show_name_label = this.scale >= RackSpace.NAME_LBL_SCALE_CUTOFF; - const show_owner_label = show_name_label && this.model.RBAC.can_i("view", "all"); if (this.model.showingRacks()) { - for (var rack of Array.from(this.racks)) { rack.draw(show_u_labels, show_name_label, show_owner_label); } + for (var rack of Array.from(this.racks)) { rack.draw(show_u_labels, show_name_label); } this.updateRackImage(); } if (this.model.showHoldingArea()) { @@ -1013,11 +1012,10 @@ class RackSpace { evHalfFlipped(img_id) { const show_u_labels = this.scale >= RackSpace.U_LBL_SCALE_CUTOFF; const show_name_label = this.scale >= RackSpace.NAME_LBL_SCALE_CUTOFF; - const show_owner_label = show_name_label && this.model.RBAC.can_i("view", "all"); // redraw the rack in the (hidden) rack layer, since the rack image in the fx layer is a slice of the rack layer it will automatically // reflect the changes - this.rackLookup[img_id].draw(show_u_labels, show_name_label, show_owner_label); + this.rackLookup[img_id].draw(show_u_labels, show_name_label); const x = this.fx.getAttribute(img_id, 'x'); const width = this.fx.getAttribute(img_id, 'sliceWidth'); @@ -1376,14 +1374,13 @@ class RackSpace { this.infoGfx.setScale(this.targetScale); this.alertGfx.setScale(this.targetScale); - // decide wether to show rack labels + // decide whether to show rack labels const show_name_label = this.targetScale >= RackSpace.NAME_LBL_SCALE_CUTOFF; const show_u_labels = this.targetScale >= RackSpace.U_LBL_SCALE_CUTOFF; - const show_owner_label = show_name_label && this.model.RBAC.can_i("view", "all"); if (this.model.showingRacks()) { for (var rack of Array.from(this.racks)) { - if (rack instanceof Rack) { rack.showOwnerLabel(show_owner_label); } + if (rack instanceof Rack) { rack.showOwnerLabel(show_name_label); } rack.showNameLabel(show_name_label); if (rack instanceof Rack) { rack.showULabels(show_u_labels, this.targetScale); } diff --git a/app/javascript/canvas/irv/view/RackSpaceDragHandler.js b/app/javascript/canvas/irv/view/RackSpaceDragHandler.js index 12ff266f0..1ffa2c016 100644 --- a/app/javascript/canvas/irv/view/RackSpaceDragHandler.js +++ b/app/javascript/canvas/irv/view/RackSpaceDragHandler.js @@ -31,6 +31,7 @@ class RackSpaceDragHandler { if (this.draggee == null || !this.canIMoveThisItem()) { // If not over a device or we're not permitted to move it abort. + this.draggee = null; return; } @@ -111,7 +112,7 @@ class RackSpaceDragHandler { } doIHavePermissionToMoveOrDrag() { - return ((this.draggee instanceof Chassis || this.draggee instanceof Machine) && this.model.RBAC.can_i_move_devices()); + return ((this.draggee instanceof Chassis || this.draggee instanceof Machine) && this.model.RBAC.can_i_move_device(this.draggee)); } // public method, updates dragging of a draggee box or device diff --git a/app/javascript/cluster_types/index.js b/app/javascript/cluster_types/index.js new file mode 100644 index 000000000..a423319f0 --- /dev/null +++ b/app/javascript/cluster_types/index.js @@ -0,0 +1,16 @@ +document.addEventListener("DOMContentLoaded", function() { + const teamSelect = document.querySelector("#choose_cluster_team"); + const cards = document.querySelectorAll(".cluster-type-card"); + + if(teamSelect && !teamSelect.disabled && cards.length > 0) { + teamSelect.addEventListener("change", function(event) { + let selectedTeamId = event.target.value; + + cards.forEach(function(link) { + link.href = `${link.dataset.baseTargetUrl}?team_id=${selectedTeamId}`; + link.classList.remove("disabled-cluster-type-card"); + link.title = ""; + }); + }); + } +}); \ No newline at end of file diff --git a/app/jobs/broadcast_rack_change_job.rb b/app/jobs/broadcast_rack_change_job.rb index 1aba5a8a6..19da6342e 100644 --- a/app/jobs/broadcast_rack_change_job.rb +++ b/app/jobs/broadcast_rack_change_job.rb @@ -1,19 +1,24 @@ class BroadcastRackChangeJob < ApplicationJob queue_as :default - def perform(rack_id, user_id, action) + def perform(rack_id, team_id, action) if action == "deleted" msg = { action: action, rack: {id: rack_id}} else - msg = rack_content(rack_id, action, user_id) + msg = rack_content(rack_id, action) end - User.where(root: true).or(User.where(id: user_id)).each do |user| + user_roles = TeamRole.where(team_id: team_id) + role_mapping = user_roles.pluck(:user_id, :role).to_h + User.where(root: true).or(User.where(id: role_mapping.keys)).each do |user| + unless action == "deleted" + role = user.root? ? "superAdmin" : role_mapping[user.id] + msg[:rack][:teamRole] = role + end InteractiveRackViewChannel.broadcast_to(user, msg) end end - def rack_content(rack_id, action, user_id) - user = User.find(user_id) - { action: action, rack: Irv::HwRackServices::Show.call(user, rack_id) } + def rack_content(rack_id, action) + { action: action, rack: Irv::HwRackServices::Show.call(rack_id) } end end diff --git a/app/jobs/broadcast_user_racks_job.rb b/app/jobs/broadcast_user_racks_job.rb new file mode 100644 index 000000000..ec60183fb --- /dev/null +++ b/app/jobs/broadcast_user_racks_job.rb @@ -0,0 +1,10 @@ +class BroadcastUserRacksJob < ApplicationJob + queue_as :default + + def perform(user_id) + user = User.find(user_id) + all_racks = Irv::HwRackServices::Index.call(user) + all_racks[:action] = "latest_full_data" + InteractiveRackViewChannel.broadcast_to(user, all_racks) + end +end diff --git a/app/jobs/create_cluster_job.rb b/app/jobs/create_cluster_job.rb index 0e5a21d80..67a43f9ab 100644 --- a/app/jobs/create_cluster_job.rb +++ b/app/jobs/create_cluster_job.rb @@ -77,7 +77,7 @@ def body { cloud_env: cloud_env_details, cluster: cluster_details, - billing_account_id: @user.billing_acct_id, + billing_account_id: @cluster.team.billing_acct_id, middleware_url: @cloud_service_config.user_handler_base_url, } end @@ -96,7 +96,7 @@ def cloud_env_details auth_url: @cloud_service_config.internal_auth_url, user_id: @user.cloud_user_id, password: @user.foreign_password, - project_id: @user.project_id + project_id: @cluster.team.project_id } end diff --git a/app/jobs/create_credit_deposit_job.rb b/app/jobs/create_credit_deposit_job.rb index 3b9fc0732..490733c36 100644 --- a/app/jobs/create_credit_deposit_job.rb +++ b/app/jobs/create_credit_deposit_job.rb @@ -3,10 +3,9 @@ class CreateCreditDepositJob < ApplicationJob queue_as :default - def perform(credit_deposit, cloud_service_config, user, **options) + def perform(credit_deposit, cloud_service_config, **options) runner = Runner.new( credit_deposit: credit_deposit, - user: user, cloud_service_config: cloud_service_config, logger: logger, **options @@ -31,9 +30,8 @@ def error_message end class Runner < HttpRequests::Faraday::JobRunner - def initialize(credit_deposit:, user:, **kwargs) + def initialize(credit_deposit:, **kwargs) @credit_deposit = credit_deposit - @user = user super(**kwargs) end @@ -56,14 +54,14 @@ def url end def path - "/add_credits" + "/credits" end def body { credits: { - billing_account_id: @credit_deposit.billing_acct_id, - credits_to_add: @credit_deposit.amount + billing_acct_id: @credit_deposit.billing_acct_id, + amount: @credit_deposit.amount } } end diff --git a/app/jobs/create_key_pair_job.rb b/app/jobs/create_key_pair_job.rb index 26a441b0e..20bd3b1f4 100644 --- a/app/jobs/create_key_pair_job.rb +++ b/app/jobs/create_key_pair_job.rb @@ -3,10 +3,11 @@ class CreateKeyPairJob < ApplicationJob queue_as :default - def perform(key_pair, cloud_service_config, user, **options) + def perform(key_pair, cloud_service_config, user, project_id, **options) runner = Runner.new( key_pair: key_pair, user: user, + project_id: project_id, cloud_service_config: cloud_service_config, logger: logger, **options @@ -32,9 +33,10 @@ def error_message end class Runner < HttpRequests::Faraday::JobRunner - def initialize(key_pair:, user:, **kwargs) + def initialize(key_pair:, user:, project_id:, **kwargs) @key_pair = key_pair @user = user + @project_id = project_id super(**kwargs) end @@ -93,7 +95,7 @@ def cloud_env_details auth_url: @cloud_service_config.internal_auth_url, user_id: @user.cloud_user_id, password: @user.foreign_password, - project_id: @user.project_id + project_id: @project_id } end end diff --git a/app/jobs/create_single_user_team_job.rb b/app/jobs/create_single_user_team_job.rb new file mode 100644 index 000000000..76e9328ca --- /dev/null +++ b/app/jobs/create_single_user_team_job.rb @@ -0,0 +1,25 @@ +class CreateSingleUserTeamJob < ApplicationJob + queue_as :default + + def perform(user, cloud_service_config) + team = nil + team_role = nil + + ActiveRecord::Base.transaction do + team = Team.new(name: "#{user.login}_team", single_user: true) + unless team.save + logger.info("Unable to create team for #{user.login} #{team.errors.details}") + raise ActiveModel::ValidationError, team + end + + team_role = TeamRole.new(team: team, user: user, role: "admin") + unless team_role.save + logger.info("Unable to create team role for #{user.login} #{team_role.errors.details}") + logger.info("Rolling back creation of team #{team.name}") + raise ActiveModel::ValidationError, team_role + end + end + + CreateTeamThenRoleJob.perform_later(team, team_role, cloud_service_config) + end +end diff --git a/app/jobs/create_team_job.rb b/app/jobs/create_team_job.rb new file mode 100644 index 000000000..283fbe649 --- /dev/null +++ b/app/jobs/create_team_job.rb @@ -0,0 +1,72 @@ +require 'faraday' + +class CreateTeamJob < ApplicationJob + queue_as :default + + retry_on ::Faraday::Error, wait: :polynomially_longer, attempts: 10 + retry_on ::ActiveModel::ValidationError, wait: :polynomially_longer, attempts: 10 + + def perform(team, cloud_service_config, **options) + if team.deleted_at + logger.info("Skipping job; team was deleted at #{team.deleted_at.inspect}") + return + end + runner = Runner.new( + team: team, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + end + + class Result + include HttpRequests::ResultSyncer + + property :project_id, context: :cloud + validates :project_id, presence: true, on: :cloud + + property :billing_acct_id, context: :billing + validates :billing_acct_id, presence: true, on: :billing + end + + class Runner < HttpRequests::Faraday::JobRunner + def initialize(team:, **kwargs) + @team = team + super(**kwargs) + end + + def call + response = super + result = Result.from(response.body) + result.validate!(:cloud) + result.sync(@team, :cloud) + result.validate!(:billing) + result.sync(@team, :billing) + rescue ::ActiveModel::ValidationError + @logger.warn("Failed to sync response to team: #{$!.message}") + raise + end + + private + + def url + "#{@cloud_service_config.user_handler_base_url}/team" + end + + def body + { + cloud_env: { + auth_url: @cloud_service_config.internal_auth_url, + user_id: @cloud_service_config.admin_user_id, + password: @cloud_service_config.admin_foreign_password, + project_id: @cloud_service_config.admin_project_id, + }, + name: @team.name + }.tap do |h| + h[:project_id] = @team.project_id unless @team.project_id.blank? + h[:billing_acct_id] = @team.billing_acct_id unless @team.billing_acct_id.blank? + end + end + end +end diff --git a/app/jobs/create_team_role_job.rb b/app/jobs/create_team_role_job.rb new file mode 100644 index 000000000..8b8b985d0 --- /dev/null +++ b/app/jobs/create_team_role_job.rb @@ -0,0 +1,97 @@ +require 'faraday' + +class CreateTeamRoleJob < ApplicationJob + queue_as :default + + def perform(team_role, cloud_service_config, **options) + runner = Runner.new( + team_role: team_role, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + end + + class Result + + def initialize(success, error_message) + @success = !!success + @error_message = error_message + end + + def success? + @success + end + + def error_message + success? ? nil : @error_message + end + end + + class Runner < HttpRequests::Faraday::JobRunner + + def initialize(team_role:, **kwargs) + @team_role = team_role + super(**kwargs.reverse_merge(test_stubs: test_stubs)) + end + + def test_stubs + nil + end + + def call + response = super + unless response.success? + return Result.new(false, "#{error_description}: #{response.reason_phrase || "Unknown error"}") + end + + if @team_role.save + return Result.new(true, "") + else + return Result.new(false, "Unable to add user to team: #{@team_role.errors.full_messages.join("; ")}") + end + rescue Faraday::Error => e + error_message = e.message + if e.response && e.response[:body] && e.response[:headers]['content-type']&.include?('application/json') + message = JSON.parse(e.response[:body])["message"] + error_message = message if message + end + Result.new(false, "#{error_description}: #{error_message}") + end + + private + + def url + "#{@cloud_service_config.user_handler_base_url}/team_role" + end + + def body + { + cloud_env: cloud_env_details, + team_role: team_role_details + } + end + + def cloud_env_details + { + auth_url: @cloud_service_config.internal_auth_url, + user_id: @cloud_service_config.admin_user_id, + password: @cloud_service_config.admin_foreign_password, + project_id: @cloud_service_config.admin_project_id, + } + end + + def team_role_details + { + role: @team_role.role, + project_id: @team_role.team.project_id, + user_id: @team_role.user.cloud_user_id + } + end + + def error_description + "Unable to submit request" + end + end +end diff --git a/app/jobs/create_team_then_role_job.rb b/app/jobs/create_team_then_role_job.rb new file mode 100644 index 000000000..b061cce15 --- /dev/null +++ b/app/jobs/create_team_then_role_job.rb @@ -0,0 +1,26 @@ +require 'faraday' + +class CreateTeamThenRoleJob < CreateTeamJob + def perform(team, team_role, cloud_service_config, **options) + runner = Runner.new( + team: team, + team_role: team_role, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + end + + class Runner < CreateTeamJob::Runner + def initialize(team_role:, **kwargs) + @team_role = team_role + super(**kwargs) + end + + def call + super + CreateTeamRoleJob.perform_later(@team_role, @cloud_service_config) + end + end +end diff --git a/app/jobs/delete_key_pair_job.rb b/app/jobs/delete_key_pair_job.rb index 17469608b..d1b2fdf83 100644 --- a/app/jobs/delete_key_pair_job.rb +++ b/app/jobs/delete_key_pair_job.rb @@ -3,10 +3,11 @@ class DeleteKeyPairJob < ApplicationJob queue_as :default - def perform(key_pair_name, cloud_service_config, user, **options) + def perform(key_pair_name, cloud_service_config, user, project_id, **options) runner = Runner.new( key_pair_name: key_pair_name, user: user, + project_id: project_id, cloud_service_config: cloud_service_config, logger: logger, **options @@ -32,9 +33,10 @@ def error_message end class Runner < HttpRequests::Faraday::JobRunner - def initialize(key_pair_name:, user:, **kwargs) + def initialize(key_pair_name:, user:, project_id:, **kwargs) @key_pair_name = key_pair_name @user = user + @project_id = project_id super(**kwargs) end @@ -81,7 +83,7 @@ def cloud_env_details auth_url: @cloud_service_config.internal_auth_url, user_id: @user.cloud_user_id, password: @user.foreign_password, - project_id: @user.project_id + project_id: @project_id } end end diff --git a/app/jobs/delete_team_job.rb b/app/jobs/delete_team_job.rb new file mode 100644 index 000000000..b91dccaa9 --- /dev/null +++ b/app/jobs/delete_team_job.rb @@ -0,0 +1,78 @@ +require 'faraday' + +class DeleteTeamJob < ApplicationJob + include GoodJob::ActiveJobExtensions::Concurrency + + queue_as :default + RETRY_ATTEMPTS = 10 + retry_on ::Faraday::Error, wait: :polynomially_longer, attempts: RETRY_ATTEMPTS + + # Allow only a single job for a given team and cloud platform. Otherwise the + # admin hammering the delete button will cause concertim to hammer the + # middleware. + good_job_control_concurrency_with( + perform_limit: 1, + enqueue_limit: 1, + key: ->{ [self.class.name, arguments[0].to_gid.to_s, arguments[1].to_gid.to_s].join('--') }, + ) + + def perform(team, cloud_service_config, **options) + # If the team doesn't have any project or billing IDs we can just delete it + # without involving the middleware. + if team.project_id.nil? && team.billing_acct_id.nil? + team.destroy! + return + end + runner = Runner.new( + team: team, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + nil + end + + class Runner < HttpRequests::Faraday::JobRunner + def initialize(team:, **kwargs) + @team = team + super(**kwargs.reverse_merge(test_stubs: test_stubs)) + end + + def test_stubs + nil + end + + def call + response = connection.delete("") do |request| + request.body = body + end + if response.success? + @team.destroy! + end + end + + private + + def url + url = URI(@cloud_service_config.user_handler_base_url) + url.path = "/team" + url.to_s + end + + def body + { + cloud_env: { + auth_url: @cloud_service_config.internal_auth_url, + user_id: @cloud_service_config.admin_user_id, + password: @cloud_service_config.admin_foreign_password, + project_id: @cloud_service_config.admin_project_id, + }, + team_info: { + billing_acct_id: @team.billing_acct_id, + project_id: @team.project_id, + } + } + end + end +end diff --git a/app/jobs/delete_team_role_job.rb b/app/jobs/delete_team_role_job.rb new file mode 100644 index 000000000..b61807546 --- /dev/null +++ b/app/jobs/delete_team_role_job.rb @@ -0,0 +1,105 @@ +require 'faraday' + +class DeleteTeamRoleJob < ApplicationJob + queue_as :default + + def perform(team_role, cloud_service_config, **options) + runner = Runner.new( + team_role: team_role, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + end + + class Result + attr_reader :status_code + + def initialize(success, error_message) + @success = !!success + @error_message = error_message + end + + def success? + @success + end + + def error_message + success? ? nil : @error_message + end + end + + class Runner < HttpRequests::Faraday::JobRunner + def initialize(team_role:, **kwargs) + @team_role = team_role + super(**kwargs.reverse_merge(test_stubs: test_stubs)) + end + + def test_stubs + nil + end + + def call + response = connection.delete(path) do |req| + req.body = body + end + + unless response.success? + return Result.new(false, "#{error_description}: #{response.reason_phrase || "Unknown error"}") + end + + begin + @team_role.destroy! + return Result.new(true, "") + rescue ActiveRecord::RecordNotDestroyed => e + return Result.new(false, "Unable to remove user from team: #{e.message}") + end + rescue Faraday::Error => e + error_message = e.message + if e.response && e.response[:body] && e.response[:headers]['content-type']&.include?('application/json') + message = JSON.parse(e.response[:body])["message"] + error_message = message if message + end + Result.new(false, "#{error_description}: #{error_message}") + end + + private + + def url + @cloud_service_config.user_handler_base_url + end + + def path + "/team_role" + end + + def body + { + cloud_env: cloud_env_details, + team_role: team_role_details + } + end + + def cloud_env_details + { + auth_url: @cloud_service_config.internal_auth_url, + user_id: @cloud_service_config.admin_user_id, + password: @cloud_service_config.admin_foreign_password, + project_id: @cloud_service_config.admin_project_id, + } + end + + def team_role_details + { + role: @team_role.role, + project_id: @team_role.team.project_id, + user_id: @team_role.user.cloud_user_id + } + end + + def error_description + "Unable to submit delete team role request" + end + end +end diff --git a/app/jobs/get_cloud_assets_job.rb b/app/jobs/get_cloud_assets_job.rb index 76c814828..1a3fa8360 100644 --- a/app/jobs/get_cloud_assets_job.rb +++ b/app/jobs/get_cloud_assets_job.rb @@ -1,14 +1,15 @@ require 'faraday' # GetCloudAssetsJob retrieves cloud assets from cluster builder such as the -# list of flavors, images and networks availabel to the given user. +# list of flavors, images and networks available to the given user/team. class GetCloudAssetsJob < ApplicationJob queue_as :default - def perform(cloud_service_config, user, **options) + def perform(cloud_service_config, user, team, **options) runner = Runner.new( cloud_service_config: cloud_service_config, user: user, + team: team, logger: logger, **options ) @@ -34,8 +35,9 @@ def error_message end class Runner < HttpRequests::Faraday::JobRunner - def initialize(user:, **kwargs) + def initialize(user:, team:, **kwargs) @user = user + @team = team super(**kwargs) end @@ -68,7 +70,7 @@ def params auth_url: @cloud_service_config.internal_auth_url, user_id: @user.cloud_user_id, password: @user.foreign_password, - project_id: @user.project_id + project_id: @team.project_id } end end diff --git a/app/jobs/get_draft_invoice_job.rb b/app/jobs/get_draft_invoice_job.rb index da1238922..a09473a75 100644 --- a/app/jobs/get_draft_invoice_job.rb +++ b/app/jobs/get_draft_invoice_job.rb @@ -3,9 +3,9 @@ class GetDraftInvoiceJob < ApplicationJob queue_as :default - def perform(cloud_service_config, user, **options) + def perform(cloud_service_config, team, **options) runner = Runner.new( - user: user, + team: team, cloud_service_config: cloud_service_config, logger: logger, **options @@ -33,8 +33,8 @@ def parse_body(body) end class Runner < InvoiceBaseJob::Runner - def initialize(user:, **kwargs) - @user = user + def initialize(team:, **kwargs) + @team = team super(**kwargs) end @@ -53,7 +53,7 @@ def fake_response data = renderer.render( template: "invoices/fakes/draft", layout: false, - locals: {account_id: @user.root? ? "034796e0-4129-45cd-b2ed-fcfc27cd8a7f" : @user.billing_acct_id}, + locals: {account_id: @team.billing_acct_id}, ) build_fake_response( success: true, @@ -71,7 +71,7 @@ def url def body { invoice: { - billing_account_id: @user.billing_acct_id, + billing_acct_id: @team.billing_acct_id, target_date: Date.today.to_formatted_s(:iso8601), }, } diff --git a/app/jobs/get_invoice_job.rb b/app/jobs/get_invoice_job.rb index e158c9c49..715935f65 100644 --- a/app/jobs/get_invoice_job.rb +++ b/app/jobs/get_invoice_job.rb @@ -3,9 +3,9 @@ class GetInvoiceJob < ApplicationJob queue_as :default - def perform(cloud_service_config, user, invoice_id, **options) + def perform(cloud_service_config, team, invoice_id, **options) runner = Runner.new( - user: user, + team: team, invoice_id: invoice_id, cloud_service_config: cloud_service_config, logger: logger, @@ -28,13 +28,13 @@ def invoice private def parse_body(body) - @invoice = parse_invoice(body["account_invoice"]) + @invoice = parse_invoice(body["invoice"]) end end class Runner < InvoiceBaseJob::Runner - def initialize(user:, invoice_id:, **kwargs) - @user = user + def initialize(team:, invoice_id:, **kwargs) + @team = team @invoice_id = invoice_id super(**kwargs) end @@ -46,7 +46,7 @@ def fake_response data = renderer.render( template: "invoices/fakes/#{@invoice_id}", layout: false, - locals: {account_id: @user.root? ? "034796e0-4129-45cd-b2ed-fcfc27cd8a7f" : @user.billing_acct_id}, + locals: {account_id: @team.billing_acct_id}, ) build_fake_response( success: true, @@ -71,7 +71,7 @@ def url def body { invoice: { - billing_account_id: @user.billing_acct_id, + billing_acct_id: @team.billing_acct_id, invoice_id: @invoice_id, }, } diff --git a/app/jobs/get_invoices_job.rb b/app/jobs/get_invoices_job.rb index ab5469c51..f30c5f57a 100644 --- a/app/jobs/get_invoices_job.rb +++ b/app/jobs/get_invoices_job.rb @@ -3,9 +3,9 @@ class GetInvoicesJob < ApplicationJob queue_as :default - def perform(cloud_service_config, user, **options) + def perform(cloud_service_config, team, **options) runner = Runner.new( - user: user, + team: team, cloud_service_config: cloud_service_config, logger: logger, **options @@ -43,8 +43,8 @@ def parse_invoices_count(body) end class Runner < InvoiceBaseJob::Runner - def initialize(user:, offset:, limit:, **kwargs) - @user = user + def initialize(team:, offset:, limit:, **kwargs) + @team = team @offset = offset @limit = limit super(**kwargs) @@ -57,7 +57,7 @@ def fake_response data = renderer.render( template: "invoices/fakes/list", layout: false, - locals: {account_id: @user.root? ? "034796e0-4129-45cd-b2ed-fcfc27cd8a7f" : @user.billing_acct_id}, + locals: {account_id: @team.billing_acct_id}, ) body = JSON.parse(data) # Return a slice of all invoices just as the real API does. @@ -74,7 +74,7 @@ def url def body { invoices: { - billing_account_id: @user.billing_acct_id, + billing_acct_id: @team.billing_acct_id, offset: @offset, limit: @limit, }, diff --git a/app/jobs/get_user_key_pairs_job.rb b/app/jobs/get_user_key_pairs_job.rb index 7ed86fd96..0afcf2097 100644 --- a/app/jobs/get_user_key_pairs_job.rb +++ b/app/jobs/get_user_key_pairs_job.rb @@ -3,9 +3,10 @@ class GetUserKeyPairsJob < ApplicationJob queue_as :default - def perform(cloud_service_config, user, **options) + def perform(cloud_service_config, user, project_id, **options) runner = Runner.new( user: user, + project_id: project_id, cloud_service_config: cloud_service_config, logger: logger, **options @@ -33,8 +34,9 @@ def error_message end class Runner < HttpRequests::Faraday::JobRunner - def initialize(user:, **kwargs) + def initialize(user:, project_id:, **kwargs) @user = user + @project_id = project_id super(**kwargs) end @@ -47,8 +49,7 @@ def call end results = response.body - key_pairs = results["key_pairs"].map do |key_pair| - details = key_pair["keypair"] + key_pairs = results["key_pairs"].map do |details| KeyPair.new(user: @user, name: details["name"], fingerprint: details["fingerprint"], key_type: details["type"] || "ssh") end return Result.new(true, key_pairs, "") @@ -74,7 +75,7 @@ def body auth_url: @cloud_service_config.internal_auth_url, user_id: @user.cloud_user_id, password: @user.foreign_password, - project_id: @user.project_id + project_id: @project_id } } end diff --git a/app/jobs/invoice_base_job.rb b/app/jobs/invoice_base_job.rb index a21591fd4..d7d097c26 100644 --- a/app/jobs/invoice_base_job.rb +++ b/app/jobs/invoice_base_job.rb @@ -44,20 +44,20 @@ def parse_body(body) raise NotImplementedError end - # Return the user that the given invoice was generated for. - def user_for_invoice(invoice_data) + # Return the team that the given invoice was generated for. + def team_for_invoice(invoice_data) billing_account_id = invoice_data["accountId"] || invoice_data["account_id"] - User.find_by(billing_acct_id: billing_account_id).tap do |user| - if user.nil? + Team.find_by(billing_acct_id: billing_account_id).tap do |team| + if team.nil? invoice_id = invoice_data["invoiceId"] || invoice_data["invoice_id"] - Rails.logger.warn("Unable to find matching user for invoice. invoice_id:#{invoice_id} account_id:#{billing_account_id}") + Rails.logger.warn("Unable to find matching team for invoice. invoice_id:#{invoice_id} account_id:#{billing_account_id}") end end end def parse_invoice(invoice_data) Invoice.new.tap do |invoice| - invoice.account = user_for_invoice(invoice_data) + invoice.account = team_for_invoice(invoice_data) (invoice.attribute_names - %w(account items)).each do |attr| invoice.send("#{attr}=", invoice_data[attr] || invoice_data[attr.camelize(:lower)]) end diff --git a/app/jobs/request_status_change_job.rb b/app/jobs/request_status_change_job.rb index 91e9b5947..d9f90b4b4 100644 --- a/app/jobs/request_status_change_job.rb +++ b/app/jobs/request_status_change_job.rb @@ -38,6 +38,7 @@ class Runner < HttpRequests::Faraday::JobRunner def initialize(target:, type:, action:, user:, **kwargs) @target = target @type = type + @rack = @target.is_a?(HwRack) ? @target : @target.rack @action = action @user = user super(**kwargs) @@ -73,7 +74,7 @@ def body auth_url: @cloud_service_config.internal_auth_url, user_id: (@user.root? ? @cloud_service_config.admin_user_id : @user.cloud_user_id), password: @user.root? ? @cloud_service_config.admin_foreign_password : @user.foreign_password, - project_id: @user.root? ? @cloud_service_config.admin_project_id : @user.project_id + project_id: @user.root? ? @cloud_service_config.admin_project_id : @rack.team.project_id }, action: @action } diff --git a/app/jobs/update_team_role_job.rb b/app/jobs/update_team_role_job.rb new file mode 100644 index 000000000..43cb46578 --- /dev/null +++ b/app/jobs/update_team_role_job.rb @@ -0,0 +1,102 @@ +require 'faraday' + +class UpdateTeamRoleJob < ApplicationJob + queue_as :default + + def perform(team_role, new_role, cloud_service_config, **options) + runner = Runner.new( + team_role: team_role, + new_role: new_role, + cloud_service_config: cloud_service_config, + logger: logger, + **options + ) + runner.call + end + + class Result + attr_reader :status_code + + def initialize(success, error_message) + @success = !!success + @error_message = error_message + end + + def success? + @success + end + + def error_message + success? ? nil : @error_message + end + end + + class Runner < HttpRequests::Faraday::JobRunner + + def initialize(team_role:, new_role:, **kwargs) + @team_role = team_role + @new_role = new_role + super(**kwargs.reverse_merge(test_stubs: test_stubs)) + end + + def test_stubs + nil + end + + def call + response = connection.patch("", body) + + unless response.success? + return Result.new(false, "#{error_description}: #{response.reason_phrase || "Unknown error"}") + end + + if @team_role.update(role: @new_role) + return Result.new(true, "") + else + return Result.new(false, "Unable to update team role: #{@team_role.errors.full_messages.join("; ")}") + end + rescue Faraday::Error => e + error_message = e.message + if e.response && e.response[:body] && e.response[:headers]['content-type']&.include?('application/json') + message = JSON.parse(e.response[:body])["message"] + error_message = message if message + end + Result.new(false, "#{error_description}: #{error_message}") + end + + private + + def url + "#{@cloud_service_config.user_handler_base_url}/team_role" + end + + def body + { + cloud_env: cloud_env_details, + team_role: team_role_details + } + end + + def cloud_env_details + { + auth_url: @cloud_service_config.internal_auth_url, + user_id: @cloud_service_config.admin_user_id, + password: @cloud_service_config.admin_foreign_password, + project_id: @cloud_service_config.admin_project_id, + } + end + + def team_role_details + { + current_role: @team_role.role, + new_role: @new_role, + project_id: @team_role.team.project_id, + user_id: @team_role.user.cloud_user_id + } + end + + def error_description + "Unable to submit request" + end + end +end diff --git a/app/jobs/user_deletion_job.rb b/app/jobs/user_deletion_job.rb index fc7759567..dd608da8a 100644 --- a/app/jobs/user_deletion_job.rb +++ b/app/jobs/user_deletion_job.rb @@ -1,5 +1,6 @@ require 'faraday' +# This can be simplified now there's just cloud user to delete class UserDeletionJob < ApplicationJob include GoodJob::ActiveJobExtensions::Concurrency @@ -17,12 +18,10 @@ class UserDeletionJob < ApplicationJob ) def perform(user, cloud_service_config, **options) - # If the user doesn't have any cloud or billing IDs we can just delete it + # If the user doesn't have a cloud ID we can just delete it # without involving the middleware. - if user.cloud_user_id.nil? && user.project_id.nil? && user.billing_acct_id.nil? - user.destroy! - return - end + return user.destroy! if user.cloud_user_id.nil? + runner = Runner.new( user: user, cloud_service_config: cloud_service_config, @@ -56,7 +55,7 @@ def call def url url = URI(@cloud_service_config.user_handler_base_url) - url.path = "/delete_user" + url.path = "/user" url.to_s end @@ -69,9 +68,7 @@ def body project_id: @cloud_service_config.admin_project_id, }, user_info: { - billing_acct_id: @user.billing_acct_id, cloud_user_id: @user.cloud_user_id, - project_id: @user.project_id, } } end diff --git a/app/jobs/user_signup_job.rb b/app/jobs/user_signup_job.rb index 56fa89e92..6af0bf36a 100644 --- a/app/jobs/user_signup_job.rb +++ b/app/jobs/user_signup_job.rb @@ -23,13 +23,8 @@ def perform(user, cloud_service_config, **options) class Result include HttpRequests::ResultSyncer - property :cloud_user_id, from: :user_id, context: :cloud - property :project_id, context: :cloud + property :cloud_user_id, from: :user_cloud_id, context: :cloud validates :cloud_user_id, presence: true, on: :cloud - validates :project_id, presence: true, on: :cloud - - property :billing_acct_id, from: :billing_account_id, context: :billing - validates :billing_acct_id, presence: true, on: :billing end class Runner < HttpRequests::Faraday::JobRunner @@ -43,8 +38,7 @@ def call result = Result.from(response.body) result.validate!(:cloud) result.sync(@user, :cloud) - result.validate!(:billing) - result.sync(@user, :billing) + CreateSingleUserTeamJob.perform_later(@user, @cloud_service_config) rescue ::ActiveModel::ValidationError @logger.warn("Failed to sync response to user: #{$!.message}") raise @@ -53,7 +47,7 @@ def call private def url - "#{@cloud_service_config.user_handler_base_url}/create_user_project" + "#{@cloud_service_config.user_handler_base_url}/user" end def body @@ -65,13 +59,10 @@ def body project_id: @cloud_service_config.admin_project_id, }, username: @user.login, + name: @user.name, password: @user.foreign_password, email: @user.email - }.tap do |h| - h[:cloud_user_id] = @user.cloud_user_id unless @user.cloud_user_id.blank? - h[:project_id] = @user.project_id unless @user.project_id.blank? - h[:billing_account_id] = @user.billing_acct_id unless @user.billing_acct_id.blank? - end + } end end end diff --git a/app/jobs/user_update_job.rb b/app/jobs/user_update_job.rb index 3a7948bd7..bf1d464d7 100644 --- a/app/jobs/user_update_job.rb +++ b/app/jobs/user_update_job.rb @@ -6,10 +6,9 @@ class UserUpdateJob < ApplicationJob retry_on ::Faraday::Error, wait: :polynomially_longer, attempts: RETRY_ATTEMPTS def perform(user, changes, cloud_service_config, **options) - # If the user doesn't have any cloud or billing IDs there is no need to involve the middleware. - if user.cloud_user_id.nil? && user.billing_acct_id.nil? - return - end + # If the user doesn't have a cloud ID there is no need to involve the middleware. + return if user.cloud_user_id.nil? + runner = Runner.new( user: user, changes: changes, @@ -33,7 +32,7 @@ def test_stubs end def call - response = super + response = connection.patch("", body) if response.success? @user.foreign_password = @user.pending_foreign_password @user.pending_foreign_password = nil @@ -45,7 +44,7 @@ def call def url url = URI(@cloud_service_config.user_handler_base_url) - url.path = "/change_user_details" + url.path = "/user" url.to_s end @@ -58,7 +57,6 @@ def body project_id: @cloud_service_config.admin_project_id, }, user_info: { - billing_acct_id: @user.billing_acct_id, cloud_user_id: @user.cloud_user_id, new_data: {}.tap do |h| h[:email] = @user.email if @changes[:email] diff --git a/app/models/ability.rb b/app/models/ability.rb index 2570f8ad8..20a8f2cc3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -6,10 +6,6 @@ def initialize(user) enable_abilities end - def enough_credits_to_create_cluster? - @user.credits > 0 && @user.credits >= Rails.application.config.cluster_credit_requirement - end - private def enable_abilities @@ -32,11 +28,7 @@ def root_abilities # Don't allow any admin users to be deleted. cannot :destroy, User, root: true - # Don't allow admins to receive credits - cannot :create, CreditDeposit do |deposit| - user = deposit.user - user.root || user.project_id.nil? || user.billing_acct_id.nil? - end + cannot :manage, TeamRole, team: Team.where(single_user: true) end # Abilities for non-root users. @@ -46,23 +38,29 @@ def non_root_abilities can :read, InteractiveRackView can :read, Template - can :manage, Chassis, location: {rack: {user: @user}} - can :manage, Device, chassis: {location: {rack: {user: @user}}} - can :manage, HwRack, user: @user + can :read, Chassis, location: {rack: {team_id: @user.team_ids }} + can :read, Device, chassis: {location: {rack: {team_id: @user.team_ids }}} + can [:read, :devices], HwRack, team_id: @user.team_ids + can :manage, Chassis, location: {rack: {team_id: @user.teams_where_admin.pluck(:id) }} + can :manage, Device, chassis: {location: {rack: {team_id: @user.teams_where_admin.pluck(:id) }}} + can :manage, HwRack, team_id: @user.teams_where_admin.pluck(:id) + can :manage, RackviewPreset, user: @user can :read, ClusterType - can :create, Cluster if enough_credits_to_create_cluster? + can :create, Cluster, team_id: @user.teams_where_admin.pluck(:id) can :read, KeyPair, user: @user can :create, KeyPair, user: @user can :destroy, KeyPair, user: @user can [:read, :update], User, id: @user.id + can :read, Team, id: @user.team_ids + can :manage, TeamRole, team: @user.teams_where_admin.where(single_user: false) # Invoice is an ActiveModel::Model, but not an ActiveRecord::Base. Setting # abilities like this might not work too well. Or perhaps its fine. - can :read, Invoice, account: @user + can :read, Invoice, account: @user.team_roles.where(role: "admin").map(&:team) end # Despite specifying what a user can/can't do, you will eventually come diff --git a/app/models/cluster.rb b/app/models/cluster.rb index 40ce522b1..00c7c2513 100644 --- a/app/models/cluster.rb +++ b/app/models/cluster.rb @@ -12,9 +12,8 @@ class Cluster #################################### attr_accessor :cluster_type + attr_accessor :team attr_accessor :name - attr_accessor :fields - attr_accessor :field_groups attr_reader :selections #################################### @@ -24,7 +23,12 @@ class Cluster #################################### validates :cluster_type, - presence: true + presence: true + + validates :team, + presence: true + + validate :team_has_enough_credits? validates :name, presence: true, @@ -40,19 +44,34 @@ class Cluster # #################################### - def initialize(cluster_type:, name: nil, cluster_params: nil, selections: {}) + def initialize(cluster_type:, team: nil, name: nil, cluster_params: nil, selections: {}) @cluster_type = cluster_type + @team = team @name = name @selections = selections - @field_groups = Cluster::FieldGroups.new(self, cluster_type.field_groups, cluster_type.fields) - @fields = @field_groups.fields - fields.each { |field| field.value = cluster_params[field.id] } if cluster_params + @cluster_params = cluster_params + end + + def field_groups + @field_groups ||= Cluster::FieldGroups.new(self, cluster_type.field_groups, cluster_type.fields) + end + + def fields + return @fields if @fields + + @fields = self.field_groups.fields + @fields.each { |field| field.value = @cluster_params[field.id] } if @cluster_params + @fields end def type_id @cluster_type.foreign_id end + def team_id + @team&.id + end + def field_values {}.tap do |field_values| fields.each do |field| @@ -81,10 +100,18 @@ def add_field_error(field_or_id, error) #################################### def valid_fields? + return unless cluster_type + fields.each do |field| unless field.valid? errors.add(field.label, field.errors.messages_for(:value).join("; ")) end end end + + def team_has_enough_credits? + if team_id && Team.meets_cluster_credit_requirement.where(id: team_id).empty? + errors.add(:team, "Has insufficient credits to launch a cluster") + end + end end diff --git a/app/models/credit_deposit.rb b/app/models/credit_deposit.rb index ead95022b..45caf082c 100644 --- a/app/models/credit_deposit.rb +++ b/app/models/credit_deposit.rb @@ -11,11 +11,11 @@ class CreditDeposit presence: true, numericality: { greater_than: 0 } - validates :user, + validates :team, presence: true - validate :user_not_root - + validate :team_has_project_id + validate :team_has_billing_account #################################### # @@ -23,8 +23,8 @@ class CreditDeposit # #################################### - attr_accessor :amount, :user - delegate :billing_acct_id, to: :user + attr_accessor :amount, :team + delegate :billing_acct_id, to: :team ############################ # @@ -32,20 +32,25 @@ class CreditDeposit # ############################ - def initialize(user:, amount: 1) - @user = user + def initialize(team:, amount: 1) + @team = team @amount = amount end - ###################################### + ############################ # # Private Instance Methods # - ###################################### + ############################ private - def user_not_root - errors.add(:user, "cannot be an admin") if user&.root + def team_has_project_id + errors.add(:team, "must have a project id") if team && !team.project_id end + + def team_has_billing_account + errors.add(:team, "must have a billing account id") if team && !team.billing_acct_id + end + end diff --git a/app/models/device.rb b/app/models/device.rb index ec2b93589..cfe83414d 100644 --- a/app/models/device.rb +++ b/app/models/device.rb @@ -31,8 +31,8 @@ class Device < ApplicationRecord belongs_to :chassis, foreign_key: :base_chassis_id has_one :rack, through: :chassis, source: :rack has_one :location, through: :chassis, source: :location - has_one :template, through: :chassis, source: :template + has_one :team, through: :rack belongs_to :details, polymorphic: :true, dependent: :destroy @@ -47,8 +47,8 @@ class Device < ApplicationRecord presence: true, length: { maximum: 150 }, format: { - with: /\A[a-zA-Z0-9\-]*\Z/, - message: "can contain only alphanumeric characters and hyphens." + with: /\A[a-zA-Z0-9\-\.]*\Z/, + message: "can contain only alphanumeric characters, dots and hyphens." } validates :status, presence: true, @@ -102,14 +102,19 @@ class Device < ApplicationRecord #################################### def valid_action?(action) + return false unless compute_device? + VALID_STATUS_ACTION_MAPPINGS[status].include?(action) end + def compute_device? + self.details_type == "Device::ComputeDetails" + end + def openstack_id metadata['openstack_instance_id'] end - ############################ # # Private Instance Methods diff --git a/app/models/hw_rack.rb b/app/models/hw_rack.rb index 143adc7ee..46e254957 100644 --- a/app/models/hw_rack.rb +++ b/app/models/hw_rack.rb @@ -35,7 +35,7 @@ class HwRack < ApplicationRecord has_many :chassis, through: :locations has_many :devices, through: :chassis - belongs_to :user, class_name: 'User' + belongs_to :team ############################ @@ -46,7 +46,7 @@ class HwRack < ApplicationRecord validates :name, presence: true, - uniqueness: {scope: :user}, + uniqueness: {scope: :team}, format: { with: /\A[a-zA-Z0-9\-\_]*\Z/, message: "can contain only alphanumeric characters, hyphens and underscores." @@ -78,7 +78,7 @@ def set_defaults # The remaining defaults take their value from that given to the last # rack. - last_rack = HwRack.where(user: user).order(:created_at).last + last_rack = HwRack.where(team: team).order(:created_at).last self.u_height ||= last_rack.nil? ? 42 : last_rack.u_height self.name ||= @@ -87,7 +87,7 @@ def set_defaults sprintf("%0#{$1.length}d%s", $1.to_i + 1, $2) end else - "Rack-#{HwRack.where(user: user).count + 1}" + "Rack-#{HwRack.where(team: team).count + 1}" end end @@ -174,6 +174,6 @@ def metadata_format end def broadcast_change(action) - BroadcastRackChangeJob.perform_now(self.id, self.user_id, action) + BroadcastRackChangeJob.perform_now(self.id, self.team_id, action) end end diff --git a/app/models/interactive_rack_view.rb b/app/models/interactive_rack_view.rb index af58b84c0..6f78ee636 100644 --- a/app/models/interactive_rack_view.rb +++ b/app/models/interactive_rack_view.rb @@ -10,7 +10,12 @@ class InteractiveRackView # Canvas functions class << self - def get_structure(racks=nil, user) + def get_structure(racks=nil, user=nil) + unless racks || user + Rails.logger.debug("Argument error: must have racks or a user") + return ['Missing arguments'] + end + sql = generate_sql(racks, user) begin xml = ApplicationRecord.connection.exec_query(sql).rows.join @@ -47,11 +52,25 @@ def rack_ids(racks, user) else nil end - permitted_ids = HwRack.accessible_by(user.ability).pluck('id') - if requested_ids.nil? - permitted_ids + if user + permitted_ids = HwRack.accessible_by(user.ability).pluck('id') + if requested_ids.nil? + permitted_ids + else + requested_ids & permitted_ids + end + else + requested_ids + end + end + + def role_query(user) + return unless user + + if user.root? + "( SELECT 'superAdmin' as \"teamRole\" ) as \"teamRole\"," else - requested_ids & permitted_ids + "( SELECT TR.role AS \"teamRole\" FROM team_roles TR WHERE TR.team_id = R.team_id AND TR.user_id = '#{user.id.to_s}' LIMIT 1) AS \"teamRole\"," end end @@ -62,10 +81,10 @@ def generate_sql(racks, user) ret = (< { where("credits >= ?", Rails.application.config.cluster_credit_requirement) } + normalizes :project_id, with: -> project_id { project_id.strip } + normalizes :name, with: -> name { name.strip } + + ############################ + # + # Class Methods + # + ############################ + + def self.perform_search(term, search_scope = default_searchable_columns, include_users=true) + matches = super(term, search_scope) + return matches unless include_users + + matching_users = User.perform_search(term, [:name], false) + return matches if matching_users.empty? + + matching_team_roles = TeamRole.where(user_id: matching_users) + matches.or(Team.where(id: matching_team_roles.pluck(:team_id))) + end + + #################################### + # + # Associations + # + #################################### + + has_many :team_roles, + dependent: :destroy + has_many :users, through: :team_roles + has_many :racks, + class_name: 'HwRack', + dependent: :destroy + + ############################ + # + # Validations + # + ############################ + + validates :name, + presence: true, + uniqueness: true, + length: { maximum: 56 }, + format: { + with: /\A[a-zA-Z0-9\-_\s]*\z/, + message: "can contain only alphanumeric characters, spaces, hyphens and underscores." + } + + validates :project_id, + uniqueness: true, + length: { maximum: 255 }, + allow_nil: true, + allow_blank: true + + validates :billing_acct_id, + uniqueness: true, + length: { maximum: 255 }, + allow_nil: true, + allow_blank: true + + validates :cost, + numericality: { greater_than_or_equal_to: 0 }, + allow_blank: true + validates :credits, + numericality: { greater_than_or_equal_to: 0 }, + presence: true + validates :billing_period_end, comparison: { greater_than: :billing_period_start }, + unless: -> { billing_period_start.blank? || billing_period_end.blank? } + validate :billing_period_start_today_or_ealier, + if: -> { billing_period_start && billing_period_start_changed? } + validate :billing_period_end_today_or_later, + if: -> { billing_period_end && billing_period_end_changed? } + validate :complete_billing_period + + #################################### + # + # Public Instance Methods + # + #################################### + + def inactive_message + # If the account is pending deletion, we return :invalid to be + # indistinguishable from the account not existing. + deleted_at.nil? ? super : :invalid + end + + def mark_as_pending_deletion + update(deleted_at: Time.current) + end + + #################################### + # + # Private Instance Methods + # + #################################### + + private + + def complete_billing_period + unless !!billing_period_start == !!billing_period_end + errors.add(:billing_period, 'must have a start date and end date, or neither') + end + end + + def billing_period_start_today_or_ealier + if billing_period_start && billing_period_start > Date.current + errors.add(:billing_period_start, 'must be today or earlier') + end + end + + def billing_period_end_today_or_later + if billing_period_end && billing_period_end < Date.current + errors.add(:billing_period_end, 'must be today or later') + end + end +end diff --git a/app/models/team_role.rb b/app/models/team_role.rb new file mode 100644 index 000000000..e6610f32c --- /dev/null +++ b/app/models/team_role.rb @@ -0,0 +1,91 @@ +class TeamRole < ApplicationRecord + include Searchable + default_search_scope :role + + ############################ + # + # Class Methods + # + ############################ + + def self.perform_search(term, search_scope = default_searchable_columns) + matches = super + matching_users = User.perform_search(term, [:name], false) + return matches if matching_users.empty? + + matches.or(TeamRole.where(user_id: matching_users.pluck(:id))) + end + + ############################ + # + # Constants + # + ############################ + + VALID_ROLES = %w(admin member) + + ############################ + # + # Associations + # + ############################ + + belongs_to :user + belongs_to :team + + ############################ + # + # Validations + # + ############################ + + validates :role, + presence: true, + inclusion: { in: VALID_ROLES, message: "%{value} is not a valid role" } + + validates :user_id, uniqueness: { scope: :team_id, message: "User can only have one role per team" } + + validate :user_not_root + validate :one_role_for_single_user_team + + ###################################### + # + # Hooks + # + ###################################### + + after_commit :broadcast_change + + ############################ + # + # Public Instance Methods + # + ############################ + + def user_name + self.user.name + end + + ############################ + # + # Private Instance Methods + # + ############################ + + private + + def user_not_root + self.errors.add(:user, 'must not be super admin') if user&.root? + end + + def one_role_for_single_user_team + if team&.single_user && team.team_roles.where.not(id: id).exists? + self.errors.add(:team, 'is a single user team and already has an assigned user') + end + end + + # What user can see in irv may have changed + def broadcast_change + BroadcastUserRacksJob.perform_now(self.user_id) + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 818eea655..b27877319 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -4,27 +4,39 @@ class User < ApplicationRecord include Devise::JWT::RevocationStrategies::Allowlist include Searchable - default_search_scope :login, :name, :cloud_user_id, :project_id, :billing_acct_id + default_search_scope :login, :name, :cloud_user_id encrypts :foreign_password encrypts :pending_foreign_password - #################################### + ############################ # - # Associations + # Class Methods # - #################################### + ############################ - has_many :racks, - class_name: 'HwRack', - dependent: :destroy + def self.perform_search(term, search_scope = default_searchable_columns, include_teams = true) + matches = super(term, search_scope) + return matches unless include_teams + + matching_teams = Team.perform_search(term, [:name], false) + return matches if matching_teams.empty? + + matching_team_roles = TeamRole.where(team_id: matching_teams) + matches.or(User.where(id: matching_team_roles.pluck(:user_id))) + end #################################### # - # Hooks + # Associations # #################################### - before_validation :strip_project_id + + has_many :team_roles, + dependent: :destroy + + has_many :teams, through: :team_roles + has_many :racks, through: :teams ############################### @@ -43,33 +55,12 @@ class User < ApplicationRecord format: { with: /\A[a-zA-Z0-9\-\_\.]*\Z/, message: "can contain only alphanumeric characters, hyphens, underscores and periods."} validates :email, presence: true - validates :project_id, - uniqueness: true, - length: { maximum: 255 }, - allow_nil: true, - allow_blank: true + validates :cloud_user_id, - uniqueness: true, - allow_nil: true, - allow_blank: true - validates :credits, - numericality: true, - presence: true - validates :cost, - numericality: { greater_than_or_equal_to: 0 }, - allow_blank: true - validates :billing_acct_id, - uniqueness: true, - length: { maximum: 255 }, - allow_nil: true, - allow_blank: true - validates :billing_period_end, comparison: { greater_than: :billing_period_start }, - unless: -> { billing_period_start.blank? || billing_period_end.blank? } - validate :billing_period_start_today_or_ealier, - if: -> { billing_period_start && billing_period_start_changed? } - validate :billing_period_end_today_or_later, - if: -> { billing_period_end && billing_period_end_changed? } - validate :complete_billing_period + uniqueness: true, + length: { maximum: 255 }, + allow_nil: true, + allow_blank: true #################################### # @@ -134,33 +125,7 @@ def mark_as_pending_deletion allowlisted_jwts.destroy_all end - #################################### - # - # Private Instance Methods - # - #################################### - - private - - def strip_project_id - self.project_id = nil if self.project_id.blank? - end - - def complete_billing_period - unless !!billing_period_start == !!billing_period_end - errors.add(:billing_period, 'must have a start date and end date, or neither') - end - end - - def billing_period_start_today_or_ealier - if billing_period_start && billing_period_start > Date.current - errors.add(:billing_period_start, 'must be today or earlier') - end - end - - def billing_period_end_today_or_later - if billing_period_end && billing_period_end < Date.current - errors.add(:billing_period_end, 'must be today or later') - end + def teams_where_admin + @teams_where_admin ||= teams.where(team_roles: { role: 'admin' }) end end diff --git a/app/presenters/api/v1/rack_presenter.rb b/app/presenters/api/v1/rack_presenter.rb index 1dc648c11..043720bbd 100644 --- a/app/presenters/api/v1/rack_presenter.rb +++ b/app/presenters/api/v1/rack_presenter.rb @@ -18,8 +18,8 @@ def chassis @chassis ||= o.chassis.map { |c| Api::V1::ChassisPresenter.new(c) } end - def user - @user ||= Api::V1::UserPresenter.new(o.user) + def team + @user ||= Api::V1::TeamPresenter.new(o.team) end end end diff --git a/app/presenters/api/v1/team_presenter.rb b/app/presenters/api/v1/team_presenter.rb new file mode 100644 index 000000000..a202b030e --- /dev/null +++ b/app/presenters/api/v1/team_presenter.rb @@ -0,0 +1,20 @@ +# +# Api::V1::UserPresenter +# +# Team Presenter for the API +module Api::V1 + class TeamPresenter < Presenter + + # Be selective about what attributes and methods we expose. + delegate :id, :name, :project_id, :billing_acct_id, :billing_period_start, :billing_period_end, :cost, :credits, + to: :o + + def status + if o.deleted_at.nil? + "active" + else + "pending deletion" + end + end + end +end diff --git a/app/presenters/api/v1/user_presenter.rb b/app/presenters/api/v1/user_presenter.rb index 0602b1595..c20a639a7 100644 --- a/app/presenters/api/v1/user_presenter.rb +++ b/app/presenters/api/v1/user_presenter.rb @@ -4,11 +4,9 @@ # User Presenter for the API module Api::V1 class UserPresenter < Presenter - include Costed # Be selective about what attributes and methods we expose. - delegate :id, :login, :name, :email, :cloud_user_id, :project_id, :root?, - :billing_period_start, :billing_period_end, :billing_acct_id, + delegate :id, :login, :name, :email, :cloud_user_id, :root?, :team_roles, to: :o def status diff --git a/app/presenters/team_presenter.rb b/app/presenters/team_presenter.rb new file mode 100644 index 000000000..87f707317 --- /dev/null +++ b/app/presenters/team_presenter.rb @@ -0,0 +1,60 @@ +class TeamPresenter < Presenter + include Costed + + def name(user) + personal_team_for_user?(user) ? "#{o.name} (your personal team)" : o.name + end + + def status + if o.deleted_at.nil? + "Active" + else + "Pending deletion" + end + end + + def delete_confirmation_message + "Are you sure you want to delete team #{o.name}?" \ + " This will delete all of their racks and devices." + end + + def billing_period + return "pending (awaiting update)" unless o.billing_period_start && o.billing_period_end + + "#{o.billing_period_start.strftime("%Y/%m/%d")} - #{o.billing_period_end.strftime("%Y/%m/%d")}" + end + + def team_users_list(role) + o.team_roles.where(role: role).map {|team_role| team_role.user.name }.join(", ") + end + + def formatted_credits + '%.2f' % o.credits + end + + def project_id_form_hint + form_hint(:project_id) + end + + def billing_acct_id_form_hint + form_hint(:billing_acct_id) + end + + def possible_new_users + @possible_users ||= User.where.not(id: o.user_ids).where.not(root: true) + end + + private + + def form_hint(attribute) + if o.send(attribute).blank? + I18n.t("simple_form.customisations.hints.team.edit.#{attribute}.blank") + else + I18n.t("simple_form.customisations.hints.team.edit.#{attribute}.present") + end + end + + def personal_team_for_user?(user) + o.single_user && !user.root && user.teams_where_admin.where(id: o.id).exists? + end +end diff --git a/app/presenters/team_role_presenter.rb b/app/presenters/team_role_presenter.rb new file mode 100644 index 000000000..f07323001 --- /dev/null +++ b/app/presenters/team_role_presenter.rb @@ -0,0 +1,37 @@ +class TeamRolePresenter < Presenter + delegate :id, :role, :team_id, :user_id, to: :o + + def requires_confirmation?(current_user) + role == "admin" && (own_role?(current_user) || only_admin?) + end + + def own_role?(current_user) + current_user == o.user + end + + def only_admin? + @only_admin ||= TeamRole.where(team_id: team_id, role: "admin").where.not(id: id).empty? + end + + def delete_confirmation(current_user) + message = "" + if only_admin? + message = "This is the only admin user for the team. Removing them will limit access to the team.\n\n" + end + if own_role?(current_user) + message << "This will remove your personal access to the team.\n\n" + end + message << "Do you wish to continue?" + end + + def edit_confirmation(current_user) + message = "" + if only_admin? + message = "This is the only admin user for the team. Changing their role will limit access to the team.\n\n" + end + if own_role?(current_user) + message << "This will change your personal access to the team.\n\n" + end + message << "Do you wish to continue?" + end +end diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index c1858959e..a3acc87ea 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -1,15 +1,8 @@ class UserPresenter < Presenter - include Costed - - def billing_period - return "pending (awaiting update)" unless o.billing_period_start && o.billing_period_end - - "#{o.billing_period_start.strftime("%Y/%m/%d")} - #{o.billing_period_end.strftime("%Y/%m/%d")}" - end def authorization if o.root? - "Administrator" + "Super Admin" else "User" end @@ -24,26 +17,17 @@ def status end def delete_confirmation_message - "Are you sure you want to delete user #{o.name} (#{o.login})?" \ - " This will delete all of their racks and devices." + "Are you sure you want to delete user #{o.name} (#{o.login})?" end - def formatted_credits - '%.2f' % o.credits + def team_role_list + o.team_roles.map {|team_role| "#{team_role.team.name} (#{team_role.role})" }.sort.join(", ") end def cloud_user_id_form_hint form_hint(:cloud_user_id) end - def project_id_form_hint - form_hint(:project_id) - end - - def billing_acct_id_form_hint - form_hint(:billing_acct_id) - end - private def form_hint(attribute) diff --git a/app/services/hw_rack_services/create.rb b/app/services/hw_rack_services/create.rb deleted file mode 100644 index a90ed62f1..000000000 --- a/app/services/hw_rack_services/create.rb +++ /dev/null @@ -1,35 +0,0 @@ -# -# HwRackServices::Create -# -# Attempts to build and save a new rack based on the passed-in parameters. -# -module HwRackServices - class Create - - def self.call(rack_params, user) - new(rack_params, user).call - end - - def initialize(rack_params, user) - @rack_params = rack_params - @creating_user = user - end - - def call - rack = HwRack.new(rack_params) - rack.save - rack - end - - private - - def rack_params - if @creating_user.root? - @rack_params - else - @rack_params.delete(:user_id) - @rack_params.merge(user: @creating_user) - end - end - end -end diff --git a/app/services/irv/hw_rack_services/index.rb b/app/services/irv/hw_rack_services/index.rb index 1ee723ca1..753cf96c5 100644 --- a/app/services/irv/hw_rack_services/index.rb +++ b/app/services/irv/hw_rack_services/index.rb @@ -28,7 +28,7 @@ def call @racks = @user.root? ? HwRack.all : @user.racks @racks = @racks.where(id: @rack_ids) if @rack_ids&.any? @racks = @racks.map { |rack| Api::V1::RackPresenter.new(rack) } - renderer = Rabl::Renderer.new('api/v1/irv/racks/index', @racks, view_path: 'app/views', format: 'hash') + renderer = Rabl::Renderer.new('api/v1/irv/racks/index', @racks, view_path: 'app/views', format: 'hash', locals: { user: @user} ) {Racks: {Rack: renderer.render}} else # The fast and awkward to understand method. diff --git a/app/services/irv/hw_rack_services/show.rb b/app/services/irv/hw_rack_services/show.rb index ffa3a71f6..427342d15 100644 --- a/app/services/irv/hw_rack_services/show.rb +++ b/app/services/irv/hw_rack_services/show.rb @@ -2,8 +2,11 @@ module Irv module HwRackServices class Show < Irv::HwRackServices::Index - def initialize(user, rack_id, slow) - @user = user + def self.call(rack_id, slow=false) + new(rack_id, slow).call + end + + def initialize(rack_id, slow) @rack_id = rack_id @slow = slow end diff --git a/app/services/team_services/delete.rb b/app/services/team_services/delete.rb new file mode 100644 index 000000000..93434afd1 --- /dev/null +++ b/app/services/team_services/delete.rb @@ -0,0 +1,14 @@ +module TeamServices + class Delete + def self.call(team) + config = CloudServiceConfig.first + if config.nil? + Rails.logger.info("Unable to delete team: CloudServiceConfig has not been created") + return false + end + + team.mark_as_pending_deletion + DeleteTeamJob.perform_later(team, config) + end + end +end diff --git a/app/views/api/v1/devices/show.rabl b/app/views/api/v1/devices/show.rabl index 4200cda4b..8e12e87c8 100644 --- a/app/views/api/v1/devices/show.rabl +++ b/app/views/api/v1/devices/show.rabl @@ -8,9 +8,9 @@ end glue :details do |details| extends "api/v1/devices/details/#{details.class.name.split('::').last.underscore}" - #node :type do |details| - # details.class.name - #end + node :type do |details| + details.class.name + end end attribute :template_id, unless: @include_full_template_details diff --git a/app/views/api/v1/irv/racks/index.rabl b/app/views/api/v1/irv/racks/index.rabl index b3d951503..c70e39c0e 100644 --- a/app/views/api/v1/irv/racks/index.rabl +++ b/app/views/api/v1/irv/racks/index.rabl @@ -1,4 +1,4 @@ collection @racks node do |rack| - partial('api/v1/irv/racks/show', :object => rack) + partial('api/v1/irv/racks/show', object: rack, locals: { user: locals[:user] }) end diff --git a/app/views/api/v1/irv/racks/show.rabl b/app/views/api/v1/irv/racks/show.rabl index d71e01307..d2fa2308e 100644 --- a/app/views/api/v1/irv/racks/show.rabl +++ b/app/views/api/v1/irv/racks/show.rabl @@ -2,9 +2,16 @@ object @rack attributes :id, :name attribute :currency_cost => :cost attributes u_height: :uHeight, status: :buildStatus +node(:teamRole) do |rack| + if locals[:user].root + "superAdmin" + else + locals[:user].team_roles.where(team_id: rack.team_id).pluck(:role).first + end +end -child :user, root: 'owner' do - extends 'api/v1/users/show' +child :team, root: 'owner' do + extends 'api/v1/teams/show' end node(:template) do |rack| @@ -34,7 +41,8 @@ child(:chassis, root: 'Chassis') do |foo| id: chassis.device.id, name: chassis.device.name, buildStatus: chassis.device.status, - cost: chassis.device.currency_cost + cost: chassis.device.currency_cost, + type: chassis.device.details_type }, } end diff --git a/app/views/api/v1/racks/index.rabl b/app/views/api/v1/racks/index.rabl index cc843bfab..6c7d8b05d 100644 --- a/app/views/api/v1/racks/index.rabl +++ b/app/views/api/v1/racks/index.rabl @@ -1,4 +1,4 @@ object @racks node do |rack| - partial('api/v1/racks/show', :object => rack) + partial('api/v1/racks/show', object: rack) end diff --git a/app/views/api/v1/racks/show.rabl b/app/views/api/v1/racks/show.rabl index 9daa72557..09cd1ba32 100644 --- a/app/views/api/v1/racks/show.rabl +++ b/app/views/api/v1/racks/show.rabl @@ -1,8 +1,8 @@ object @rack attributes :id, :name, :u_height, :metadata, :status, :cost, :network_details, :creation_output, :order_id, :modified_timestamp -child :user, root: 'owner', if: current_user.root? do - extends 'api/v1/users/show' +child :team, root: 'owner' do + extends 'api/v1/teams/show' end child :devices, root: 'devices', object_root: false, if: @include_occupation_details do diff --git a/app/views/api/v1/team_roles/show.rabl b/app/views/api/v1/team_roles/show.rabl new file mode 100644 index 000000000..6d2ee48e7 --- /dev/null +++ b/app/views/api/v1/team_roles/show.rabl @@ -0,0 +1,2 @@ +object @team_role +attributes :id, :team_id, :user_id diff --git a/app/views/api/v1/teams/index.rabl b/app/views/api/v1/teams/index.rabl new file mode 100644 index 000000000..8993882f7 --- /dev/null +++ b/app/views/api/v1/teams/index.rabl @@ -0,0 +1,4 @@ +object @teams +node do |team| + partial('api/v1/teams/show', object: team) +end diff --git a/app/views/api/v1/teams/show.rabl b/app/views/api/v1/teams/show.rabl new file mode 100644 index 000000000..3624dd519 --- /dev/null +++ b/app/views/api/v1/teams/show.rabl @@ -0,0 +1,2 @@ +object @team +attributes :id, :name, :project_id, :billing_acct_id, :billing_period_start, :billing_period_end, :cost, :credits diff --git a/app/views/api/v1/users/show.rabl b/app/views/api/v1/users/show.rabl index 4588ac276..46e6fc7c3 100644 --- a/app/views/api/v1/users/show.rabl +++ b/app/views/api/v1/users/show.rabl @@ -5,10 +5,9 @@ node :fullname do |user| end attribute :email attribute :cloud_user_id, if: ->(user) { !user.root? } -attribute :project_id, if: ->(user) { !user.root? } -attribute :cost, if: ->(user) { !user.root? } -attribute :billing_period_start, if: ->(user) { !user.root? } -attribute :billing_period_end, if: ->(user) { !user.root? } -attribute :billing_acct_id, if: ->(user) { !user.root? } attribute root?: :root attribute :status + +child :team_roles do + extends 'api/v1/team_roles/show' +end diff --git a/app/views/cloud_service_configs/_actions.html.erb b/app/views/cloud_service_configs/_actions.html.erb index d52871c0a..7aba0a72c 100644 --- a/app/views/cloud_service_configs/_actions.html.erb +++ b/app/views/cloud_service_configs/_actions.html.erb @@ -1,6 +1,6 @@ <%= render_action_dropdown 'Cloud environment actions' do |builder| - builder.add title: 'View', path: cloud_service_config_path - builder.add title: 'Edit', path: edit_cloud_service_config_path + builder.add text: 'View', path: cloud_service_config_path + builder.add text: 'Edit', path: edit_cloud_service_config_path end %> diff --git a/app/views/cluster_types/_card.html.erb b/app/views/cluster_types/_card.html.erb index 71f4f427a..ae787efe3 100644 --- a/app/views/cluster_types/_card.html.erb +++ b/app/views/cluster_types/_card.html.erb @@ -1,17 +1,30 @@ <% cluster_type = card %> - -<% if current_user.can?(:create, Cluster) %> - <%= link_to(new_cluster_type_cluster_path(cluster_type), class: "cluster-type-card") do %> - <%= render partial: 'card_content', object: cluster_type %> +<% disabled = true %> +<% if team %> + <% cluster = Cluster.new(cluster_type: cluster_type, team: team) %> + <% if current_user.can?(:create, cluster) %> + <% disabled = false %> + <% title = "" %> + <% else %> + <% title = if Team.meets_cluster_credit_requirement.where(id: team.id).exists? + "You do not have permission to create clusters" + else + "Insufficient credits. Your chosen team must have at least #{Rails.application.config.cluster_credit_requirement} credits to create a cluster" + end + %> <% end %> <% else %> - <% title = if current_ability.enough_credits_to_create_cluster? - "You do not have permission to create clusters" - else - "Insufficient credits. You must have at least #{Rails.application.config.cluster_credit_requirement} credits to create a cluster" - end + <% title = if !current_user.can?(:create, Cluster) + "You do not have permission to create this cluster" + elsif !available_teams + "Unable to create a cluster - you must be admin for a team with at least #{Rails.application.config.cluster_credit_requirement} credits to create a cluster" + else + "Please select a team" + end %> - - <%= render partial: 'card_content', object: cluster_type %> - +<% end %> +<%= link_to( disabled ? "#" : new_cluster_type_cluster_path(cluster_type, team_id: team&.id), + { class: "cluster-type-card #{"disabled-cluster-type-card" if disabled }", title: title, + data: { base_target_url: new_cluster_type_cluster_path(cluster_type) } }) do %> + <%= render partial: 'card_content', object: cluster_type %> <% end %> diff --git a/app/views/cluster_types/index.html.erb b/app/views/cluster_types/index.html.erb index da62a346b..5ad5964d9 100644 --- a/app/views/cluster_types/index.html.erb +++ b/app/views/cluster_types/index.html.erb @@ -1,4 +1,7 @@ -<% set_title "Select cluster type" %> +<% set_title "Create cluster - select cluster type" %> +<% content_for(:head) do %> + <%= javascript_import_module_tag "cluster_types/index" %> +<% end %>

    @@ -19,9 +22,36 @@ <% elsif @cluster_types.empty? %>

    There are no cluster types available at present.

    <% else %> +
    + <% if @valid_teams.exists? %> +
    +
    + <%= label_tag :team_id, "Team", class: "required_field" %> + +
    +
    + You must be a team admin and the team have at least <%= Rails.application.config.cluster_credit_requirement %> credits +
    +
    + <% else %> +

    + You must be an admin for a team with at least <%= Rails.application.config.cluster_credit_requirement %> credits to create a cluster. +

    + <% end %> +
    +
    + <% available_teams = @valid_teams.exists? %> <% @cluster_types.each do |cluster_type| %> - <%= render partial: 'card', object: cluster_type %> + <%= render partial: 'card', object: cluster_type, locals: { team: @team, available_teams: available_teams } %> <% end %>
    <% end %> diff --git a/app/views/clusters/_actions.html.erb b/app/views/clusters/_actions.html.erb index c2513ec87..b7a6730fb 100644 --- a/app/views/clusters/_actions.html.erb +++ b/app/views/clusters/_actions.html.erb @@ -2,6 +2,7 @@ <%= render_lhm_actions("Cluster actions") do |actions| - actions.add title: "Manage key-pairs", path: key_pairs_path + actions.add text: "Manage key-pairs", path: key_pairs_path + actions.add text: "Re-select cluster type or team", path: cluster_types_path(team_id: @team) end %> diff --git a/app/views/clusters/new.html.erb b/app/views/clusters/new.html.erb index b5213eed4..23dc681ba 100644 --- a/app/views/clusters/new.html.erb +++ b/app/views/clusters/new.html.erb @@ -2,7 +2,7 @@ <%= javascript_import_module_tag "clusters/new" %> <% end %> -<% set_title 'Configure cluster' -%> +<% set_title "Create cluster for team #{@team.name} - configure cluster" -%> <% content_for(:side_content) do %> <%= render 'actions' %> <% end %> @@ -15,8 +15,8 @@ Complete the details below and click "Launch".

    - <%= form_for(@cluster, url: cluster_type_clusters_path(@cluster_type)) do |f| %> + <%= f.hidden_field :team_id %> <%= cell('cluster_form_errors').(:show, @cluster) %> <%= cell('cluster_form_name').(:show, @cluster, f) %> diff --git a/app/views/credit_deposits/new.html.erb b/app/views/credit_deposits/new.html.erb index 13f5a73a7..84c3fbd5e 100644 --- a/app/views/credit_deposits/new.html.erb +++ b/app/views/credit_deposits/new.html.erb @@ -1,10 +1,10 @@ <% set_title 'Add credits' -%> -

    Add credits for user <%= @user.name %>.

    +

    Add credits for team <%= @team.name %>.

    -

    They currently have <%= pluralize(presenter_for(@user).formatted_credits, 'credit') %>.

    +

    It currently has <%= pluralize(presenter_for(@team).formatted_credits, 'credit') %>.

    -<%= simple_form_for @credit_deposit, html: { class: 'no_border' } do |f| %> +<%= simple_form_for @credit_deposit, html: { class: 'no_border' }, url: team_credit_deposits_path(@team) do |f| %> <%= f.input :amount, as: :float, input_html: { min: 0.01, step: 0.01 } %> <%= f.button :submit %> <% end %> diff --git a/app/views/interactive_rack_views/_actions.html.erb b/app/views/interactive_rack_views/_actions.html.erb index eb67e853e..02eea4f5d 100644 --- a/app/views/interactive_rack_views/_actions.html.erb +++ b/app/views/interactive_rack_views/_actions.html.erb @@ -1,6 +1,6 @@ <%= render_action_dropdown 'Rack actions' do |builder| - builder.add title: 'Export data', path: '#', id: :export_link + builder.add text: 'Export data', path: '#', id: :export_link end %> diff --git a/app/views/interactive_rack_views/_configuration.json b/app/views/interactive_rack_views/_configuration.json index 16bf6716b..4e905f550 100644 --- a/app/views/interactive_rack_views/_configuration.json +++ b/app/views/interactive_rack_views/_configuration.json @@ -362,7 +362,7 @@ "*COMMENT*" : "e.g. for a rack and chassis options will also be displayed for a device", "verbose" : false, "urlInternalPrefix" : "internal::", - "spacer" : "", + "spacer" : "", "aspectMap": { @@ -404,15 +404,18 @@ }, { "caption" : "View details", - "url" : "/racks/[[rack_id]]" + "url" : "/racks/[[rack_id]]", + "rbacAction" : "view", + "newTab": true }, - { + { "caption": "[[spacer]]" - }, + }, { "caption" : "Destroy", "url" : "internal::statusChangeRequest,destroy,racks,[[rack_id]],[[rack_name]]", - "availableToBuildStatuses": ["STOPPED", "ACTIVE", "FAILED"] + "availableToBuildStatuses": ["STOPPED", "ACTIVE", "FAILED"], + "rbacAction" : "manage" } ], @@ -423,56 +426,66 @@ }, { "caption" : "Focus", - "url" : "internal::focusOn,chassis,[[chassis_id]]" + "url" : "internal::focusOn,chassis,[[chassis_id]]", + "rbacAction" : "view" } ], "devices": - [ - { - "content" : "

    Device [[device_name]]

    " - }, - { - "caption" : "Focus", - "url" : "internal::focusOn,devices,[[device_id]]" - }, - { - "caption" : "View details", - "url" : "/devices/[[device_id]]" - }, - { - "caption": "[[spacer]]" - }, - { - "caption" : "Switch off", - "url" : "internal::statusChangeRequest,off,devices,[[device_id]],[[device_name]]", - "availableToBuildStatuses": ["ACTIVE"] - }, - { - "caption" : "Switch on", - "url" : "internal::statusChangeRequest,on,devices,[[device_id]],[[device_name]]", - "availableToBuildStatuses": ["STOPPED"] - }, - { - "caption" : "Suspend", - "url" : "internal::statusChangeRequest,suspend,devices,[[device_id]],[[device_name]]", - "availableToBuildStatuses": ["ACTIVE"] - }, - { - "caption" : "Resume", - "url" : "internal::statusChangeRequest,resume,devices,[[device_id]],[[device_name]]", - "availableToBuildStatuses": ["SUSPENDED"] - }, - { - "caption" : "Destroy", - "url" : "internal::statusChangeRequest,destroy,devices,[[device_id]],[[device_name]]", - "availableToBuildStatuses": ["STOPPED", "ACTIVE", "SUSPENDED", "FAILED"] - } - ], - - "common": - [ - ] + { + "common": [ + { + "content" : "

    Device [[device_name]]

    " + }, + { + "caption" : "Focus", + "url" : "internal::focusOn,devices,[[device_id]]", + "rbacAction" : "view" + }, + { + "caption" : "View details", + "url" : "/devices/[[device_id]]", + "rbacAction" : "view", + "newTab": true + } + ], + "Device::ComputeDetails": [ + { + "caption": "[[spacer]]" + }, + { + "caption" : "Switch off", + "url" : "internal::statusChangeRequest,off,devices,[[device_id]],[[device_name]]", + "availableToBuildStatuses": ["ACTIVE"], + "rbacAction" : "manage" + }, + { + "caption" : "Switch on", + "url" : "internal::statusChangeRequest,on,devices,[[device_id]],[[device_name]]", + "availableToBuildStatuses": ["STOPPED"], + "rbacAction" : "manage" + }, + { + "caption" : "Suspend", + "url" : "internal::statusChangeRequest,suspend,devices,[[device_id]],[[device_name]]", + "availableToBuildStatuses": ["ACTIVE"], + "rbacAction" : "manage" + }, + { + "caption" : "Resume", + "url" : "internal::statusChangeRequest,resume,devices,[[device_id]],[[device_name]]", + "availableToBuildStatuses": ["SUSPENDED"], + "rbacAction" : "manage" + }, + { + "caption" : "Destroy", + "url" : "internal::statusChangeRequest,destroy,devices,[[device_id]],[[device_name]]", + "availableToBuildStatuses": ["STOPPED", "ACTIVE", "SUSPENDED", "FAILED"], + "rbacAction" : "manage" + } + ] + }, + "common": [] }, "actionPaths": diff --git a/app/views/invoices/index.html.erb b/app/views/invoices/index.html.erb index 84f05bff5..986089dfc 100644 --- a/app/views/invoices/index.html.erb +++ b/app/views/invoices/index.html.erb @@ -1,8 +1,8 @@ -<% set_title "Invoices" -%> +<% set_title "Invoices for team #{@team.name}" -%> <% content_for :title_actions do - link_to 'Preview invoice', draft_invoices_path, + link_to 'Preview invoice', draft_team_invoices_path(@team), title: 'Preview invoice for current billing period', class: 'button' end @@ -21,6 +21,6 @@ <% t.custom_column "Amount" do |invoice| invoice.formatted_amount_charged end %> <% t.actions_column do |actions, invoice| %> - <% actions.add_with_auth can: :show, on: invoice, title: 'View invoice', path: invoice_path(invoice) %> + <% actions.add_with_auth can: :show, on: invoice, text: 'View invoice', path: team_invoice_path(@team, invoice) %> <% end %> <% end %> diff --git a/app/views/invoices/show.html.erb b/app/views/invoices/show.html.erb index 0cd804501..4717e31a4 100644 --- a/app/views/invoices/show.html.erb +++ b/app/views/invoices/show.html.erb @@ -1,4 +1,4 @@ -<% set_title(@invoice.draft? ? "Draft Invoice" : "Invoice ##{@invoice.invoice_number}") -%> +<% set_title("#{@invoice.draft? ? "Draft Invoice" : "Invoice ##{@invoice.invoice_number}"} for team #{@team.name}") -%>
    diff --git a/app/views/key_pairs/_form.html.erb b/app/views/key_pairs/_form.html.erb index 4a9e2398e..790731c1b 100644 --- a/app/views/key_pairs/_form.html.erb +++ b/app/views/key_pairs/_form.html.erb @@ -14,12 +14,12 @@
    <%= f.label :name, "Key name", class: 'required_field' %> - <%= f.text_field :name, autocomplete: :off, pattern: "^[a-zA-Z][a-zA-Z0-9\\-_]*$" %> + <%= f.text_field :name, autocomplete: :off, pattern: "^[a-zA-Z][a-zA-Z0-9\\-_]*$", required: true %>
    <%= f.label :key_type, "Key type", class: 'required_field' %> - <%= f.select :key_type, [["SSH Key", "ssh"]] %> + <%= f.select :key_type, [["SSH Key", "ssh"]], required: true %>
    diff --git a/app/views/racks/devices.html.erb b/app/views/racks/devices.html.erb index 055df40ef..fd6b20669 100644 --- a/app/views/racks/devices.html.erb +++ b/app/views/racks/devices.html.erb @@ -16,7 +16,7 @@ t.attribute_column :cost, sortable: true t.actions_column do |actions, device| - actions.add title: 'View details', path: device_path(device) + actions.add text: 'View details', path: device_path(device) end end end diff --git a/app/views/racks/show.html.erb b/app/views/racks/show.html.erb index 8977a3183..fbb6f9ae3 100644 --- a/app/views/racks/show.html.erb +++ b/app/views/racks/show.html.erb @@ -13,8 +13,8 @@ dl.item "Cost this billing period:", presenter_for(@rack, RackPresenter).currency_cost dl.sublist "Owner" do |sl| - sl.item "Name", @rack.user.name - sl.item "Username", @rack.user.login + sl.item "Name", @rack.team.name + sl.item "Project ID", @rack.team.project_id end dl.item "Network details", @rack.network_details diff --git a/app/views/registrations/edit.html.erb b/app/views/registrations/edit.html.erb index 7136e34e7..d0f0699da 100644 --- a/app/views/registrations/edit.html.erb +++ b/app/views/registrations/edit.html.erb @@ -18,45 +18,6 @@
    <% end %> - <% unless current_user.root? %> -
    - <% - tooltip = current_user.project_id.blank? ? "A project ID will be automatically created" : "Once set project IDs can only be changed by an admin user" - %> - <%= f.label :project_id, "Project ID", title: tooltip %> - <%= f.text_field :project_id, autocomplete: :off, disabled: true %> -
    - <% end %> - - <% unless current_user.root? %> -
    - <% - tooltip = current_user.billing_acct_id.blank? ? "A billing account ID will be automatically created" : "Once set billing account IDs can only be changed by an admin user" - %> - <%= f.label :billing_acct_id, "Billing account ID", title: tooltip %> - <%= f.text_field :billing_acct_id, autocomplete: :off, disabled: true %> -
    - <% end %> - - <% unless current_user.root? %> - <% user = user_presenter(current_user) %> -
    - <%= f.label :billing_period, "Current billing period" %> - <%= f.text_field :billing_period, autocomplete: :off, disabled: true, value: user.billing_period %> -
    - -
    - <% tooltip = "Total cumulative costs accrued so far this billing period" %> - <%= f.label :cost, "Costs this billing period", title: tooltip %> - <%= f.text_field :cost, autocomplete: :off, disabled: true, value: user.cost %> -
    - -
    - <%= f.label :credits, "Available credits" %> - <%= f.text_field :credits, autocomplete: :off, disabled: true, value: user.formatted_credits %> -
    - <% end %> -
    <%= f.label :password %> (leave blank if you don't want to change it) <%= f.password_field :password %> diff --git a/app/views/team_roles/edit.html.erb b/app/views/team_roles/edit.html.erb new file mode 100644 index 000000000..7b6465e05 --- /dev/null +++ b/app/views/team_roles/edit.html.erb @@ -0,0 +1,11 @@ +<% + set_title "Edit user role for #{@team_role.user_name} in team #{@team_role.team.name}" +-%> + +<%= simple_form_for [@team, @team_role], html: {class: 'no_border'} do |f| %> + <%= f.input :user_id, collection: [@team_role.user], label_method: :name, value_method: :id, disabled: true %> + <%= f.input :role, collection: TeamRole::VALID_ROLES, include_blank: false %> + <% presenter = presenter_for(@team_role) %> + <%= f.button :submit, data: presenter.requires_confirmation?(current_user) ? { confirm: presenter.edit_confirmation(current_user) } : {} + %> +<% end %> diff --git a/app/views/team_roles/index.html.erb b/app/views/team_roles/index.html.erb new file mode 100644 index 000000000..3d55f3c60 --- /dev/null +++ b/app/views/team_roles/index.html.erb @@ -0,0 +1,31 @@ +<% set_title "Manage users for team #{@team.name}" -%> + +<% if current_user.can?(:create, TeamRole.new(team_id: @team.id)) %> + <%= link_to "+ Add user to team", new_team_team_role_path, class: "button" %> +<% end %> + +
    + <%= render_resource_table_for @team_roles, searchable: true do |t| %> + <% t.on_empty_collection do %> + <% capture do %> + No team roles have been found. + <% end %> + <% end %> + + <% t.attribute_column :user_id, sortable: true %> + <% t.attribute_column :user_name %> + <% t.attribute_column :role, sortable: true %> + <% t.actions_column do |actions, team_role| %> + <% actions.add_with_auth can: :edit, on: team_role, text: 'Edit role', path: edit_team_role_path(team_role) %> + <% presenter = presenter_for(team_role) %> + <% + actions.add_with_auth(can: :destroy, on: team_role, + text: 'Remove from team', + path: team_role_path(team_role), + method: 'delete', + data: presenter.requires_confirmation?(current_user) ? {confirm: presenter.delete_confirmation(current_user)} : {}, + ) + %> + <% end %> + <% end %> +
    diff --git a/app/views/team_roles/new.html.erb b/app/views/team_roles/new.html.erb new file mode 100644 index 000000000..b43c8c14a --- /dev/null +++ b/app/views/team_roles/new.html.erb @@ -0,0 +1,14 @@ +<% + set_title "Add user to team #{@team.name}" +-%> + +<% presenter = presenter_for(@team) %> +<% if presenter.possible_new_users.exists? %> + <%= simple_form_for [@team, @team_role], html: {class: 'no_border'} do |f| %> + <%= f.input :user_id, collection: presenter.possible_new_users, label_method: :name, value_method: :id, required: true %> + <%= f.input :role, collection: TeamRole::VALID_ROLES %> + <%= f.button :submit, "Add to team" %> + <% end %> +<% else %> +

    There are currently no users eligible to join this team.

    +<% end %> diff --git a/app/views/teams/edit.html.erb b/app/views/teams/edit.html.erb new file mode 100644 index 000000000..4820ebfa0 --- /dev/null +++ b/app/views/teams/edit.html.erb @@ -0,0 +1,10 @@ +<% + set_title "Edit Team" +-%> + +<%= simple_form_for @team, html: {class: 'no_border'} do |f| %> + <%= f.input :name %> + <%= f.input :project_id, label: "Project ID", hint: presenter_for(@team).project_id_form_hint, clipboard: true %> + <%= f.input :billing_acct_id, label: "Billing Account ID", hint: presenter_for(@team).billing_acct_id_form_hint, clipboard: true %> + <%= f.button :submit %> +<% end %> diff --git a/app/views/teams/index.html.erb b/app/views/teams/index.html.erb new file mode 100644 index 000000000..ee96746e8 --- /dev/null +++ b/app/views/teams/index.html.erb @@ -0,0 +1,62 @@ +<% set_title "#{"Your " unless current_user.root? }Teams" -%> + +<% if current_user.can?(:create, Team) %> + <%= link_to "+ New Team", new_team_path, class: "button" %> +<% end %> + +<%= render_resource_table_for @teams, searchable: true do |t| %> + <% t.on_empty_collection do %> + <% capture do %> + No teams have been found. + <% end %> + <% end %> + + <% t.attribute_column :id, sortable: true %> + <% t.custom_column "Name", sortable: true, db_column: :name do |team| %> + <% presenter_for(team).name(current_user) %> + <% end %> + <% if current_user.root? %> + <% t.attribute_column :single_user, title: "Single user team", sortable: true, tooltip: "A single user team cannot have more than one user assigned" %> + <% end %> + <% TeamRole::VALID_ROLES.each do |role| %> + <% t.custom_column role.pluralize.capitalize do |team| %> + <% presenter_for(team).team_users_list(role) %> + <% end %> + <% end %> + <% if current_user.root? %> + <% t.attribute_column :project_id, title: "Project ID", sortable: true %> + <% t.attribute_column :billing_acct_id, title: "Billing Account ID", sortable: true %> + <% end %> + <% t.custom_column "Credits", sortable: true, db_column: :credits do |team| %> + <% presenter_for(team).formatted_credits %> + <% end %> + <% t.custom_column "Cost", sortable: true, db_column: :cost do |team| %> + <% presenter_for(team).currency_cost %> + <% end %> + <% t.custom_column "Status", sortable: true, db_column: :deleted_at do |team| %> + <% presenter_for(team).status %> + <% end %> + + <% t.actions_column do |actions, team| %> + <% actions.add_with_auth can: :edit, on: team, text: 'Edit', path: edit_team_path(team) %> + <% actions.add_with_auth can: :manage, on: TeamRole.new(team_id: team.id), text: 'Manage Users', path: team_team_roles_path(team) %> + <% actions.add_with_auth can: :read, on: Invoice.new(account: team), text: 'View Invoices', path: team_invoices_path(team) %> + <% actions.add_with_auth can: :create, on: CreditDeposit.new(team: team), text: 'Add Credits', path: new_team_credit_deposit_path(team) %> + <% if can? :create, Cluster.new(team: team, cluster_type: ClusterType.new) %> + <% if Team.meets_cluster_credit_requirement.where(id: team.id).exists? %> + <% actions.add text: 'Create Cluster', path: cluster_types_path(team_id: team.id) %> + <% else %> + <% actions.add text: 'Create Cluster', path: '#', class: "disabled-action", title: "Team has insufficient credits to create cluster" %> + <% end %> + <% end %> + <% + actions.add_with_auth(can: :destroy, on: team, + text: 'Delete', + path: team_path(team), + method: 'delete', + data: {confirm: presenter_for(team).delete_confirmation_message}, + ) + %> + <% end %> + +<% end %> diff --git a/app/views/teams/new.html.erb b/app/views/teams/new.html.erb new file mode 100644 index 000000000..4770ef85e --- /dev/null +++ b/app/views/teams/new.html.erb @@ -0,0 +1,8 @@ +<% + set_title "New Team" +-%> + +<%= simple_form_for @team, html: {class: 'no_border'} do |f| %> + <%= f.input :name %> + <%= f.button :submit %> +<% end %> diff --git a/app/views/users/edit.html.erb b/app/views/users/edit.html.erb index 4e9355d90..8b4090683 100644 --- a/app/views/users/edit.html.erb +++ b/app/views/users/edit.html.erb @@ -6,8 +6,6 @@ <%= f.input :name %> <% unless @user.root? %> <%= f.input :cloud_user_id, hint: presenter_for(@user).cloud_user_id_form_hint, clipboard: true %> - <%= f.input :project_id, hint: presenter_for(@user).project_id_form_hint, clipboard: true %> - <%= f.input :billing_acct_id, hint: presenter_for(@user).billing_acct_id_form_hint, clipboard: true %> <% end %> <%= f.input :password, hint: "Leave blank if you don't want to change the password." %> diff --git a/app/views/users/index.html.erb b/app/views/users/index.html.erb index 1c4e75941..44ee3d504 100644 --- a/app/views/users/index.html.erb +++ b/app/views/users/index.html.erb @@ -17,28 +17,28 @@ <% t.custom_column "Authorization Level", sortable: true, db_column: :root do |user| %> <% presenter_for(user).authorization %> <% end %> + <% t.custom_column "Teams" do |user| %> + <% presenter_for(user).team_role_list %> + <% end %> <% t.attribute_column :cloud_user_id, title: "Cloud User ID", sortable: true %> - <% t.attribute_column :project_id, title: "Project ID", sortable: true %> - <% t.attribute_column :billing_acct_id, title: "Billing Account ID", sortable: true %> <% t.custom_column "Status", sortable: true, db_column: :deleted_at do |user| %> <% presenter_for(user).status %> <% end %> <% t.actions_column do |actions, user| %> <% if user == current_user %> - <% actions.add title: 'Edit', path: edit_user_registration_path %> + <% actions.add text: 'Edit', path: edit_user_registration_path %> <% else %> - <% actions.add_with_auth can: :edit, on: user, title: 'Edit', path: edit_user_path(user) %> + <% actions.add_with_auth can: :edit, on: user, text: 'Edit', path: edit_user_path(user) %> <% end %> - <% actions.add_with_auth can: :create, on: CreditDeposit.new(user: user), title: 'Add Credits', path: new_credit_deposit_path(user) %> <% actions.add_with_auth(can: :destroy, on: user, - title: 'Delete', + text: 'Delete', path: user_path(user), method: 'delete', data: {confirm: presenter_for(user).delete_confirmation_message}, ) - %> + %> <% end %> <% end %> diff --git a/config/importmap.rb b/config/importmap.rb index 2995ae5ee..841c2285e 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -7,6 +7,7 @@ pin "key_pairs/new", to: "key_pairs/new.js" pin "metrics/index", to: "metrics/index.js" pin "clusters/new", to: "clusters/new.js" +pin "cluster_types/index", to: "cluster_types/index.js" # Utility Javascripts. pin "Profiler", to: "canvas/irv/NullProfiler.js" diff --git a/config/initializers/extended_false_values.rb b/config/initializers/extended_false_values.rb new file mode 100644 index 000000000..796269b01 --- /dev/null +++ b/config/initializers/extended_false_values.rb @@ -0,0 +1,9 @@ +# Update list of false values to include Python's False + +module ActiveModel::Type + class Boolean + existing_values = FALSE_VALUES.dup + remove_const(:FALSE_VALUES) + FALSE_VALUES = existing_values + ['False', :False] + end +end diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 312074064..3e12f762d 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -38,24 +38,26 @@ en: user has already interacted with the cloud environment such as launching clusters or uploaded key pairs, this could have unexpected consequences. + team: + edit: project_id: blank: - Under normal operation, the user's project ID will be updated + Under normal operation, the team's project ID will be updated automatically. If this has not happened for some reason, you can manually enter it here. present: - Changing the user's project ID will change the cloud environment - project that the Concertim user is linked to. If the user has + Changing the team's project ID will change the cloud environment + project that the team is linked to. If the team has already interacted with the cloud environment such as launching clusters this could have unexpected consequences. billing_acct_id: blank: - Under normal operation, the user's billing account ID will be + Under normal operation, the tem's billing account ID will be updated automatically. If this has not happened for some reason, you can manually enter it here. present: - Changing the user's billing account ID will change the billing - account that the Concertim user is linked to. If the user has + Changing the team's billing account ID will change the billing + account that the team is linked to. If the team has already interacted with the cloud environment such as launching clusters this could have unexpected consequences. diff --git a/config/navigation.rb b/config/navigation.rb index 6187987ad..7c0098d9d 100644 --- a/config/navigation.rb +++ b/config/navigation.rb @@ -5,25 +5,12 @@ navigation.items do |primary| if user_signed_in? - if !current_user.root? - user = user_presenter(current_user) - primary.item :user_credits, "Available credits: #{user.formatted_credits}", nil, - align: :right - primary.item :user_cost, "Total cost so far this billing period: #{user.currency_cost}", nil, - :link_html => {:title => "Current billing period: #{user.billing_period}"}, - align: :right - end - primary.item :youraccount, "#{current_user.name}", '#', align: :right, icon: :youraccount, - highlights_on: %r(/accounts|/key_pairs|/invoices) do |acc| + highlights_on: %r(/accounts|/key_pairs/|/key_pairs) do |acc| acc.item :acc_details, 'Account details', url_helpers.edit_user_registration_path, :icon => :details, :link => {:class => 'details'} unless current_user.root? - acc.item :view_invoices, "View invoices", url_helpers.invoices_path, - icon: :reports, - link_html: {title: "View invoices"} - acc.item :acc_details, 'Manage key-pairs', url_helpers.key_pairs_path, :icon => :key, :link => {:class => 'details'} end @@ -38,29 +25,35 @@ highlights_on: %r(/cloud-env/configs) end + if current_user.can?(:read, ClusterType) + html_options = {} + if !current_user.teams_where_admin.meets_cluster_credit_requirement.exists? + html_options[:class] = "limited-action-icon" + html_options[:title] = "You must be an admin for a team with at least #{Rails.application.config.cluster_credit_requirement} credits to create a cluster" + end + + primary.item :cluster_types, 'Launch cluster', url_helpers.cluster_types_path, + icon: :racks, + html: html_options, + highlights_on: %r(/cloud-env/(cluster-types|clusters)) + end + if current_user.can?(:manage, User) primary.item :config, 'Users', url_helpers.users_path, icon: :users, highlights_on: %r(/users) end - if current_user.can?(:manage, Setting) - primary.item :config, 'Settings', url_helpers.edit_settings_path, - icon: :config, - highlights_on: %r(/settings) + if current_user.can?(:read, Team) + primary.item :config, 'Teams', url_helpers.teams_path, + icon: :groups, + highlights_on: %r(/teams) end - if current_user.can?(:read, ClusterType) - html_options = {} - if !current_ability.enough_credits_to_create_cluster? - html_options[:class] = "limited-action-icon" - html_options[:title] = "You must have at least #{Rails.application.config.cluster_credit_requirement} credits to create a cluster" - end - - primary.item :cluster_types, 'Launch cluster', url_helpers.cluster_types_path, - icon: :racks, - html: html_options, - highlights_on: %r(/cloud-env/(cluster-types|clusters)) + if current_user.can?(:manage, Setting) + primary.item :config, 'Settings', url_helpers.edit_settings_path, + icon: :config, + highlights_on: %r(/settings) end else primary.item :login, 'Log in', url_helpers.new_user_session_path, diff --git a/config/routes.rb b/config/routes.rb index 91fd52812..79aa1a33e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,17 +42,21 @@ end end - resources :users, only: [:index, :edit, :update, :destroy] do - member do - # A placeholder action for developing the resource table used on the - # users/index page. This should be removed once we have real actions to - # go in the actions dropdown. - get :placeholder - resources :credit_deposits, only: [:new, :create] + resources :users, only: [:index, :edit, :update, :destroy] + + resource :settings, only: [:edit, :update] + + resources :teams do + resources :team_roles, only: [:index, :new, :create] + resources :invoices, only: [:index, :show] do + collection do + get 'draft' + end end + resources :credit_deposits, only: [:new, :create] end - resource :settings, only: [:edit, :update] + resources :team_roles, only: [:edit, :update, :destroy] resources :key_pairs, only: [:index, :new, :create] do collection do @@ -61,12 +65,6 @@ end end - resources :invoices, only: [:index, :show] do - collection do - get 'draft' - end - end - resources :devices, only: [:show] do resources :metrics, only: [:index] end @@ -87,6 +85,7 @@ resources :metrics, :constraints => { :id => /.*/ }, only: [:show] end resources :data_source_maps, path: 'data-source-maps', only: [:index] + resources :teams, only: [:index, :create, :update, :destroy] # For use by the interactive rack view namespace :irv do @@ -128,7 +127,7 @@ resources :users, only: [:index, :update, :destroy] do collection do # Endpoint for checking user abilities. - get :can_i, action: :can_i?, as: :ability_check + get :permissions, action: :permissions, as: :permissions # Endpoint for getting the currently signed in user. get :current end diff --git a/db/migrate/20240319145625_create_teams.rb b/db/migrate/20240319145625_create_teams.rb new file mode 100644 index 000000000..e3af95497 --- /dev/null +++ b/db/migrate/20240319145625_create_teams.rb @@ -0,0 +1,25 @@ +class CreateTeams < ActiveRecord::Migration[7.0] + def change + create_table :teams, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string :name, limit: 255, null: false + t.string :project_id, limit: 255 + t.string :billing_acct_id, limit: 255 + t.decimal :cost, default: 0.00, null: false + t.decimal :credits, default: 0.00, null: false + t.date :billing_period_start + t.date :billing_period_end + t.datetime :deleted_at + + t.timestamps + end + + add_index :teams, :billing_acct_id, unique: true, where: "NOT NULL" + add_index :teams, :project_id, unique: true, where: "NOT NULL" + add_index :teams, :deleted_at, + where: 'deleted_at IS NOT NULL', + name: 'teams_deleted_at_not_null' + add_index :teams, :deleted_at, + where: 'deleted_at IS NULL', + name: 'teams_deleted_at_null' + end +end diff --git a/db/migrate/20240319145626_create_team_roles.rb b/db/migrate/20240319145626_create_team_roles.rb new file mode 100644 index 000000000..210403678 --- /dev/null +++ b/db/migrate/20240319145626_create_team_roles.rb @@ -0,0 +1,13 @@ +class CreateTeamRoles < ActiveRecord::Migration[7.0] + def change + create_table :team_roles, id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.references :user, type: :uuid, foreign_key: true + t.references :team, type: :uuid, foreign_key: true + t.string :role, null: false + + t.timestamps + end + + add_index :team_roles, [:user_id, :team_id], unique: true + end +end diff --git a/db/migrate/20240319145627_destroy_racks.rb b/db/migrate/20240319145627_destroy_racks.rb new file mode 100644 index 000000000..d35aef57d --- /dev/null +++ b/db/migrate/20240319145627_destroy_racks.rb @@ -0,0 +1,9 @@ +class DestroyRacks < ActiveRecord::Migration[7.1] + def up + HwRack.destroy_all + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20240319145628_change_rack_associations.rb b/db/migrate/20240319145628_change_rack_associations.rb new file mode 100644 index 000000000..90e5e1b4d --- /dev/null +++ b/db/migrate/20240319145628_change_rack_associations.rb @@ -0,0 +1,6 @@ +class ChangeRackAssociations < ActiveRecord::Migration[7.0] + def change + remove_reference :racks, :user, null: false, type: :uuid, foreign_key: { on_update: :cascade, on_delete: :restrict } + add_reference :racks, :team, null: false, type: :uuid, foreign_key: { on_update: :cascade, on_delete: :restrict } + end +end diff --git a/db/migrate/20240319145629_remove_outdated_user_fields.rb b/db/migrate/20240319145629_remove_outdated_user_fields.rb new file mode 100644 index 000000000..69ce862d8 --- /dev/null +++ b/db/migrate/20240319145629_remove_outdated_user_fields.rb @@ -0,0 +1,12 @@ +class RemoveOutdatedUserFields < ActiveRecord::Migration[7.0] + def change + remove_index :users, :billing_acct_id, unique: true, where: "NOT NULL" + remove_index :users, :project_id, unique: true, where: "NOT NULL" + remove_column :users, :project_id, :string, limit: 255 + remove_column :users, :billing_acct_id, :string, limit: 255 + remove_column :users, :billing_period_start, :date + remove_column :users, :billing_period_end, :date + remove_column :users, :cost, :decimal, default: 0.00, null: false + remove_column :users, :credits, :decimal, default: 0.00, null: false + end +end diff --git a/db/migrate/20240319145630_add_single_user_to_team.rb b/db/migrate/20240319145630_add_single_user_to_team.rb new file mode 100644 index 000000000..31f7ea9ef --- /dev/null +++ b/db/migrate/20240319145630_add_single_user_to_team.rb @@ -0,0 +1,5 @@ +class AddSingleUserToTeam < ActiveRecord::Migration[7.1] + def change + add_column :teams, :single_user, :boolean, default: false, null: false + end +end diff --git a/db/schema.rb b/db/schema.rb index daa20e862..e92ea1f4c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.1].define(version: 2024_03_13_144615) do +ActiveRecord::Schema[7.1].define(version: 2024_03_19_145630) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -225,10 +225,10 @@ t.datetime "updated_at", null: false t.string "order_id" t.uuid "template_id", null: false - t.uuid "user_id", null: false + t.uuid "team_id", null: false t.index ["order_id"], name: "index_racks_on_order_id", unique: true + t.index ["team_id"], name: "index_racks_on_team_id" t.index ["template_id"], name: "index_racks_on_template_id" - t.index ["user_id"], name: "index_racks_on_user_id" end create_table "rackview_presets", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -248,6 +248,35 @@ t.datetime "updated_at", null: false end + create_table "team_roles", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "user_id" + t.uuid "team_id" + t.string "role", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id"], name: "index_team_roles_on_team_id" + t.index ["user_id", "team_id"], name: "index_team_roles_on_user_id_and_team_id", unique: true + t.index ["user_id"], name: "index_team_roles_on_user_id" + end + + create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "name", limit: 255, null: false + t.string "project_id", limit: 255 + t.string "billing_acct_id", limit: 255 + t.decimal "cost", default: "0.0", null: false + t.decimal "credits", default: "0.0", null: false + t.date "billing_period_start" + t.date "billing_period_end" + t.datetime "deleted_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "single_user", default: false, null: false + t.index ["billing_acct_id"], name: "index_teams_on_billing_acct_id", unique: true, where: "(NOT NULL::boolean)" + t.index ["deleted_at"], name: "teams_deleted_at_not_null", where: "(deleted_at IS NOT NULL)" + t.index ["deleted_at"], name: "teams_deleted_at_null", where: "(deleted_at IS NULL)" + t.index ["project_id"], name: "index_teams_on_project_id", unique: true, where: "(NOT NULL::boolean)" + end + create_table "templates", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", limit: 255, default: "", null: false t.integer "height", null: false @@ -291,24 +320,16 @@ t.string "reset_password_token", limit: 255 t.datetime "reset_password_sent_at" t.boolean "root", default: false, null: false - t.string "project_id", limit: 255 t.string "cloud_user_id" - t.decimal "cost", default: "0.0", null: false - t.date "billing_period_start" - t.date "billing_period_end" t.string "foreign_password" t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.string "billing_acct_id", limit: 255 - t.decimal "credits", default: "0.0", null: false t.datetime "deleted_at" t.string "pending_foreign_password" - t.index ["billing_acct_id"], name: "index_users_on_billing_acct_id", unique: true, where: "(NOT NULL::boolean)" t.index ["deleted_at"], name: "users_deleted_at_not_null", where: "(deleted_at IS NOT NULL)" t.index ["deleted_at"], name: "users_deleted_at_null", where: "(deleted_at IS NULL)" t.index ["email"], name: "index_users_on_email", unique: true t.index ["login"], name: "index_users_on_login", unique: true - t.index ["project_id"], name: "index_users_on_project_id", unique: true, where: "(NOT NULL::boolean)" end add_foreign_key "allowlisted_jwts", "users", on_delete: :cascade @@ -317,7 +338,9 @@ add_foreign_key "data_source_maps", "devices", on_update: :cascade, on_delete: :cascade add_foreign_key "devices", "base_chassis", on_update: :cascade, on_delete: :cascade add_foreign_key "locations", "racks", on_update: :cascade, on_delete: :restrict + add_foreign_key "racks", "teams", on_update: :cascade, on_delete: :restrict add_foreign_key "racks", "templates", on_update: :cascade, on_delete: :restrict - add_foreign_key "racks", "users", on_update: :cascade, on_delete: :restrict add_foreign_key "rackview_presets", "users", on_update: :cascade, on_delete: :cascade + add_foreign_key "team_roles", "teams" + add_foreign_key "team_roles", "users" end diff --git a/docs/api/examples/create-rack.sh b/docs/api/examples/create-rack.sh index a94b11eb1..0ae8db49c 100755 --- a/docs/api/examples/create-rack.sh +++ b/docs/api/examples/create-rack.sh @@ -15,7 +15,7 @@ BASE_URL="https://${CONCERTIM_HOST}/api/v1" AUTH_TOKEN=${AUTH_TOKEN:-$("${SCRIPT_DIR}"/get-auth-token.sh)} ORDER_ID=${1} -USER_ID=${2} +TEAM_ID=${2} NAME=${3} U_HEIGHT=${4} @@ -25,13 +25,13 @@ U_HEIGHT=${4} # * ORDER_ID is mandatory. # * NAME is optional. # * U_HEIGHT is optional. -# * USER_ID is mandatory if the rack is being created for an admin user. +# * TEAM_ID is mandatory. # Otherwise it should not be provided and will be ignored if it is. # # To achieve this, we use jq to construct a JSON document, such as # # ``` -# {"rack": {"name": "", "u_height": 42, "user_id": "3", "order_id": "42"}} +# {"rack": {"name": "", "u_height": 42, "team_id": "3", "order_id": "42"}} # ``` # # Then we pipe that document to jq passing a funky script which dives into the @@ -39,20 +39,20 @@ U_HEIGHT=${4} # resulting document would be: # # ``` -# {"rack": {"u_height": 42, "user_id": "3", "order_id": "42"}} +# {"rack": {"u_height": 42, "team_id": "3", "order_id": "42"}} # ``` # # The metadata below is hardcoded but it could be any valid JSON document. BODY=$( jq --null-input \ --arg name "${NAME}" \ - --arg user_id "${USER_ID}" \ + --arg team_id "${TEAM_ID}" \ --arg u_height "${U_HEIGHT}" \ --arg order_id "${ORDER_ID}" \ ' { "rack": { "name": $name, - "user_id": $user_id, + "team_id": $team_id, "u_height": $u_height|(try tonumber catch ""), "order_id": $order_id, "status": "IN_PROGRESS", diff --git a/docs/api/examples/delete-team.sh b/docs/api/examples/delete-team.sh new file mode 100755 index 000000000..20d4f6af1 --- /dev/null +++ b/docs/api/examples/delete-team.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +set -e +set -o pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# The base URL against which relative URLs are constructed. +CONCERTIM_HOST=${CONCERTIM_HOST:-command.concertim.alces-flight.com} +BASE_URL="https://${CONCERTIM_HOST}/api/v1" + +# Use the specified AUTH_TOKEN or generate one. If AUTH_TOKEN is being +# generated LOGIN and PASSWORD environment variables must be set. +AUTH_TOKEN=${AUTH_TOKEN:-$("${SCRIPT_DIR}"/get-auth-token.sh)} + +TEAM_ID="${1}" +RECURSE="${2}" + +if [ "${RECURSE}" == "recurse" ] ; then + PARAMS="?recurse=true" +else + PARAMS="" +fi + +# Delete the team. +# +# If the `recurse=true` get parameter is not provided, the team will only be +# deleted if it currently has no racks or devices. If the `recurse=true` get +# parameter is provided, all of the team's racks and devices will be deleted +# along with the team. +curl -s -k \ + -H 'Accept: application/json' \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + -X DELETE "${BASE_URL}/teams/${TEAM_ID}${PARAMS}" diff --git a/docs/api/examples/list-teams.sh b/docs/api/examples/list-teams.sh new file mode 100755 index 000000000..b5602af92 --- /dev/null +++ b/docs/api/examples/list-teams.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +set -e +set -o pipefail + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +# The base URL against which relative URLs are constructed. +CONCERTIM_HOST=${CONCERTIM_HOST:-command.concertim.alces-flight.com} +BASE_URL="https://${CONCERTIM_HOST}/api/v1" + +# Use the specified AUTH_TOKEN or generate one. If AUTH_TOKEN is being +# generated LOGIN and PASSWORD environment variables must be set. +AUTH_TOKEN=${AUTH_TOKEN:-$("${SCRIPT_DIR}"/get-auth-token.sh)} + +curl -s -k \ + -H "Accept: application/json" \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + -X GET "${BASE_URL}/teams" diff --git a/docs/api/examples/update-user-cost.sh b/docs/api/examples/update-team-cost.sh similarity index 89% rename from docs/api/examples/update-user-cost.sh rename to docs/api/examples/update-team-cost.sh index 773e28a7b..7f56249e6 100755 --- a/docs/api/examples/update-user-cost.sh +++ b/docs/api/examples/update-team-cost.sh @@ -14,20 +14,20 @@ BASE_URL="https://${CONCERTIM_HOST}/api/v1" # generated LOGIN and PASSWORD environment variables must be set. AUTH_TOKEN=${AUTH_TOKEN:-$("${SCRIPT_DIR}"/get-auth-token.sh)} -USER_ID=${1} -USER_COST=${2} +TEAM_ID=${1} +TEAM_COST=${2} BILLING_PERIOD_START=${3} BILLING_PERIOD_END=${4} CREDITS=${5} BODY=$(jq --null-input \ - --arg cost "${USER_COST}" \ + --arg cost "${TEAM_COST}" \ --arg billing_period_start "${BILLING_PERIOD_START}" \ --arg billing_period_end "${BILLING_PERIOD_END}" \ --arg credits "${CREDITS}" \ ' { - "user": { + "team": { "cost": $cost, "billing_period_start": $billing_period_start, "billing_period_end": $billing_period_end, "credits": $credits } @@ -44,14 +44,14 @@ curl -s -k \ -H 'Content-Type: application/json' \ -H 'Accept: application/json' \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ - -X PATCH "${BASE_URL}/users/${USER_ID}" \ + -X PATCH "${BASE_URL}/teams/${TEAM_ID}" \ -d "${BODY}" ) if [ "${HTTP_STATUS}" == "200" ] ; then cat "$BODY_FILE" else - echo "User update failed" >&2 + echo "Team update failed" >&2 cat "$BODY_FILE" >&2 exit 1 fi diff --git a/docs/api/examples/update-user.sh b/docs/api/examples/update-user.sh index 5461abdf5..646192b12 100755 --- a/docs/api/examples/update-user.sh +++ b/docs/api/examples/update-user.sh @@ -16,19 +16,13 @@ AUTH_TOKEN=${AUTH_TOKEN:-$("${SCRIPT_DIR}"/get-auth-token.sh)} USER_ID=${1} CLOUD_USER_ID=${2} -PROJECT_ID=${3} -BILLING_ACCT_ID=${4} BODY=$(jq --null-input \ --arg cloud_user_id "${CLOUD_USER_ID}" \ - --arg project_id "${PROJECT_ID}" \ - --arg billing_acct_id "${BILLING_ACCT_ID}" \ ' { "user": { - "cloud_user_id": $cloud_user_id, - "project_id": $project_id, - "billing_acct_id": $billing_acct_id + "cloud_user_id": $cloud_user_id } } ' diff --git a/spec/channels/interactive_rack_view_channel_spec.rb b/spec/channels/interactive_rack_view_channel_spec.rb index 248431e3f..557498abd 100644 --- a/spec/channels/interactive_rack_view_channel_spec.rb +++ b/spec/channels/interactive_rack_view_channel_spec.rb @@ -2,6 +2,7 @@ RSpec.describe InteractiveRackViewChannel, type: :channel do let(:user) { create(:user) } + let(:team) { create(:team) } let(:active_user) { user } let(:env) { instance_double('env') } let(:warden) { instance_double('warden', user: user) } @@ -29,7 +30,8 @@ end context 'with rack' do - let!(:rack) { create(:rack, user: user, template: template) } + let!(:rack) { create(:rack, team: team, template: template) } + let!(:team_role) { create(:team_role, team: team, user: user) } let(:device_template) { create(:template, :device_template) } let!(:device) { create(:device, chassis: chassis) } let(:chassis) { create(:chassis, location: location, template: device_template) } @@ -46,9 +48,9 @@ end context 'with multiple racks' do - let!(:another_rack) { create(:rack, user: user, template: template) } - let!(:different_user) { create(:user) } - let!(:different_user_rack) { create(:rack, user: different_user, template: template) } + let!(:another_rack) { create(:rack, team: team, template: template) } + let!(:different_team) { create(:team) } + let!(:different_user_rack) { create(:rack, team: different_team, template: template) } it 'includes all racks for user' do subscribe @@ -87,7 +89,7 @@ def check_rack_data(racks, data) expect(rack_data["name"]).to eq r.name expect(rack_data["uHeight"]).to eq r.u_height.to_s expect(rack_data["buildStatus"]).to eq r.status - expect(rack_data["owner"]["id"]).to eq r.user.id.to_s + expect(rack_data["owner"]["id"]).to eq r.team.id.to_s expect(rack_data["template"]["id"]).to eq r.template.id.to_s if r.devices.exists? expect(rack_data["Chassis"]["Slots"]["Machine"]["id"]).to eq r.devices.last.id.to_s diff --git a/spec/factories/clusters.rb b/spec/factories/clusters.rb index bed7509e6..40ef470d6 100644 --- a/spec/factories/clusters.rb +++ b/spec/factories/clusters.rb @@ -2,6 +2,7 @@ factory :cluster, class: 'Cluster' do name { 'mycluster' } cluster_type { create(:cluster_type) } + team { create(:team, :with_openstack_details, credits: 1000) } initialize_with { new(**attributes) } end diff --git a/spec/factories/credit_deposit.rb b/spec/factories/credit_deposit.rb index 5a5190936..5a24c8221 100644 --- a/spec/factories/credit_deposit.rb +++ b/spec/factories/credit_deposit.rb @@ -1,7 +1,7 @@ FactoryBot.define do factory :credit_deposit, class: 'CreditDeposit' do amount { rand(1..10) } - user { create(:user, :with_openstack_details) } + team { create(:team, :with_openstack_details) } end initialize_with { new(**attributes) } diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb index 49d0533df..9a0e33ae2 100644 --- a/spec/factories/invoices.rb +++ b/spec/factories/invoices.rb @@ -12,7 +12,7 @@ refund_adj { 0 } status { 'COMMITTED' } - association :account, factory: :user + association :account, :with_openstack_details, factory: :team items { [] } initialize_with { new(attributes) } diff --git a/spec/factories/racks.rb b/spec/factories/racks.rb index ada2bbbc1..e40960f9c 100644 --- a/spec/factories/racks.rb +++ b/spec/factories/racks.rb @@ -7,6 +7,6 @@ status { 'IN_PROGRESS' } order_id { Faker::Alphanumeric.alphanumeric(number: 10) } - association :user + association :team end end diff --git a/spec/factories/team_roles.rb b/spec/factories/team_roles.rb new file mode 100644 index 000000000..7c1e2826e --- /dev/null +++ b/spec/factories/team_roles.rb @@ -0,0 +1,10 @@ +require 'faker' + +FactoryBot.define do + factory :team_role, class: 'TeamRole' do + role { "member" } + + association :user,:with_openstack_account + association :team + end +end diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb new file mode 100644 index 000000000..8c9314cd7 --- /dev/null +++ b/spec/factories/teams.rb @@ -0,0 +1,24 @@ +require 'faker' + +FactoryBot.define do + factory :team, class: 'Team' do + sequence(:name) { |n| "Team #{n}" } + project_id { nil } + billing_acct_id { nil } + end + + trait :with_openstack_details do + project_id { Faker::Alphanumeric.alphanumeric(number: 10) } + billing_acct_id { Faker::Alphanumeric.alphanumeric(number: 10) } + end + + trait :with_empty_rack do + after(:create) do |team, context| + rack_template = Template.default_rack_template + if rack_template.nil? + rack_template = create(:template, :rack_template) + end + create(:rack, team: team, template: rack_template) + end + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 82e555e8b..a99b9c903 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -5,18 +5,14 @@ sequence(:login) { |n| "user-#{n}" } sequence(:name) { |n| "User #{n}" } email { "#{login}@example.com" } - project_id { nil } cloud_user_id { nil } - billing_acct_id { nil } root { false } password { SecureRandom.alphanumeric } end - trait :with_openstack_details do - project_id { Faker::Alphanumeric.alphanumeric(number: 10) } + trait :with_openstack_account do cloud_user_id { Faker::Alphanumeric.alphanumeric(number: 10) } - billing_acct_id { Faker::Alphanumeric.alphanumeric(number: 10) } end trait :admin do @@ -25,13 +21,35 @@ root { true } end - trait :with_empty_rack do + trait :member_of_empty_rack do after(:create) do |user, context| rack_template = Template.default_rack_template if rack_template.nil? rack_template = create(:template, :rack_template) end - create(:rack, user: user, template: rack_template) + rack = create(:rack, template: rack_template) + create(:team_role, team: rack.team, user: user) end end + + trait :with_team_role do + transient do + role { 'member' } + team { create(:team) } + end + + after(:create) do |user, evaluator| + user.team_roles.create!(role: evaluator.role, team: evaluator.team) + end + end + + trait :as_team_member do + with_team_role + role { 'member' } + end + + trait :as_team_admin do + with_team_role + role { 'admin' } + end end diff --git a/spec/jobs/broadcast_rack_change_job_spec.rb b/spec/jobs/broadcast_rack_change_job_spec.rb index 151ec3f79..4b52fc5c4 100644 --- a/spec/jobs/broadcast_rack_change_job_spec.rb +++ b/spec/jobs/broadcast_rack_change_job_spec.rb @@ -1,14 +1,15 @@ require 'rails_helper' RSpec.describe BroadcastRackChangeJob, type: :job do - let(:user) { create(:user) } + let(:user) { create(:user, :as_team_member, team: team) } + let(:team) { create(:team) } let(:template) { create(:template, :rack_template) } let(:device_template) { create(:template, :device_template) } - let!(:rack) { create(:rack, user: user, template: template) } + let!(:rack) { create(:rack, team: team, template: template) } let!(:device) { create(:device, chassis: chassis) } let(:chassis) { create(:chassis, location: location, template: device_template) } let(:location) { create(:location, rack: rack) } - subject { BroadcastRackChangeJob.perform_now(rack.id, user.id, action) } + subject { BroadcastRackChangeJob.perform_now(rack.id, team.id, action) } context 'rack deletion' do let(:action) { "deleted" } @@ -25,12 +26,13 @@ expect(data["action"]).to eq action rack_data = data["rack"] expect(rack_data.present?).to be true - expect(rack_data["owner"]["id"]).to eq rack.user.id.to_s + expect(rack_data["owner"]["id"]).to eq rack.team.id.to_s expect(rack_data["template"]["name"]).to eq rack.template.name expect(rack_data["Chassis"]["Slots"]["Machine"]["id"]).to eq device.id.to_s expect(rack_data["id"]).to eq rack.id.to_s expect(rack_data["name"]).to eq rack.name expect(rack_data["cost"]).to eq "$0.00" + expect(rack_data["teamRole"]).to eq "member" } expect { subject }.to have_broadcasted_to(user).from_channel(InteractiveRackViewChannel).with(nil, &expected) diff --git a/spec/jobs/create_cluster_job_spec.rb b/spec/jobs/create_cluster_job_spec.rb index 09e1b9f7a..c9aec287a 100644 --- a/spec/jobs/create_cluster_job_spec.rb +++ b/spec/jobs/create_cluster_job_spec.rb @@ -4,7 +4,7 @@ let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } let(:cluster) { build(:cluster) } - let(:user) { create(:user, :with_openstack_details) } + let(:user) { create(:user, :with_openstack_account) } subject { CreateClusterJob::Runner.new(cluster: cluster, cloud_service_config: cloud_service_config, user: user) } describe "url" do @@ -87,13 +87,13 @@ class << subject "auth_url" => cloud_service_config.internal_auth_url, "user_id" => user.cloud_user_id, "password" => user.foreign_password, - "project_id" => user.project_id + "project_id" => cluster.team.project_id }) end - it "contains the users billing account id" do - expect(user.billing_acct_id).not_to be_nil - expect(subject[:billing_account_id]).to eq user.billing_acct_id + it "contains the cluster's team's billing account id" do + expect(cluster.team.billing_acct_id).not_to be_nil + expect(subject[:billing_account_id]).to eq cluster.team.billing_acct_id end it "contains the middleware url" do diff --git a/spec/jobs/create_credit_deposit_job_spec.rb b/spec/jobs/create_credit_deposit_job_spec.rb index 8900ed41e..605321eb6 100644 --- a/spec/jobs/create_credit_deposit_job_spec.rb +++ b/spec/jobs/create_credit_deposit_job_spec.rb @@ -3,10 +3,10 @@ RSpec.describe CreateCreditDepositJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } - let(:user) { create(:user, :with_openstack_details) } - let(:path) { "#{cloud_service_config.user_handler_base_url}/add_credits" } - let(:credit_deposit) { build(:credit_deposit, user: user) } - subject { CreateCreditDepositJob::Runner.new(credit_deposit: credit_deposit, cloud_service_config: cloud_service_config, user: user) } + let(:team) { create(:team, :with_openstack_details) } + let(:path) { "#{cloud_service_config.user_handler_base_url}/credits" } + let(:credit_deposit) { build(:credit_deposit, team: team) } + subject { CreateCreditDepositJob::Runner.new(credit_deposit: credit_deposit, cloud_service_config: cloud_service_config) } describe "url" do before(:each) do @@ -21,7 +21,7 @@ class << subject end it "uses a hard-coded path" do - expect(subject.path).to eq "/add_credits" + expect(subject.path).to eq "/credits" end end @@ -32,7 +32,7 @@ class << subject end it "returns a successful result" do - result = described_class.perform_now(credit_deposit, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(credit_deposit, cloud_service_config, test_stubs: stubs) expect(result).to be_success end end @@ -43,12 +43,12 @@ class << subject end it "returns an unsuccessful result" do - result = described_class.perform_now(credit_deposit, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(credit_deposit, cloud_service_config, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(credit_deposit, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(credit_deposit, cloud_service_config, test_stubs: stubs) expect(result.error_message).to eq "the server responded with status 404" end end @@ -60,12 +60,12 @@ class << subject let(:timeout) { 0.1 } it "returns an unsuccessful result" do - result = described_class.perform_now(credit_deposit, cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(credit_deposit, cloud_service_config, test_stubs: stubs, timeout: timeout) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(credit_deposit, cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(credit_deposit, cloud_service_config, test_stubs: stubs, timeout: timeout) expect(result.error_message).to eq "execution expired" end end @@ -76,8 +76,8 @@ class << subject it 'contains credit deposit details' do expect(subject[:credits]).to eq({ - "billing_account_id" => credit_deposit.billing_acct_id, - "credits_to_add" => credit_deposit.amount + "billing_acct_id" => credit_deposit.billing_acct_id, + "amount" => credit_deposit.amount }) end end diff --git a/spec/jobs/create_key_pair_job_spec.rb b/spec/jobs/create_key_pair_job_spec.rb index 1740cbcfc..cdacae316 100644 --- a/spec/jobs/create_key_pair_job_spec.rb +++ b/spec/jobs/create_key_pair_job_spec.rb @@ -3,10 +3,11 @@ RSpec.describe CreateKeyPairJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } - let(:user) { create(:user, :with_openstack_details) } + let(:user) { create(:user, :with_openstack_account) } + let(:project_id) { Faker::Alphanumeric.alphanumeric(number: 10) } let(:path) { "#{cloud_service_config.user_handler_base_url}/key_pairs" } let(:key_pair) { build(:key_pair, user: user) } - subject { CreateKeyPairJob::Runner.new(key_pair: key_pair, cloud_service_config: cloud_service_config, user: user) } + subject { CreateKeyPairJob::Runner.new(key_pair: key_pair, cloud_service_config: cloud_service_config, user: user, project_id: project_id) } describe "url" do before(:each) do @@ -32,12 +33,12 @@ class << subject end it "returns a successful result" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(result).to be_success end it 'populates private key' do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(key_pair.private_key).to eq "abc" end end @@ -48,12 +49,12 @@ class << subject end it "returns an unsuccessful result" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(result.error_message).to eq "the server responded with status 404" end end @@ -65,12 +66,12 @@ class << subject end it "returns an unsuccessful result" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs) expect(result.error_message).to eq "Key pair with that name already exists" end end @@ -82,12 +83,12 @@ class << subject let(:timeout) { 0.1 } it "returns an unsuccessful result" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs, timeout: timeout) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(key_pair, cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(key_pair, cloud_service_config, user, project_id, test_stubs: stubs, timeout: timeout) expect(result.error_message).to eq "execution expired" end end @@ -109,7 +110,7 @@ class << subject "auth_url" => cloud_service_config.internal_auth_url, "user_id" => user.cloud_user_id, "password" => user.foreign_password, - "project_id" => user.project_id + "project_id" => project_id }) end end diff --git a/spec/jobs/create_single_user_team_job_spec.rb b/spec/jobs/create_single_user_team_job_spec.rb new file mode 100644 index 000000000..fd1cc61d5 --- /dev/null +++ b/spec/jobs/create_single_user_team_job_spec.rb @@ -0,0 +1,53 @@ +require 'rails_helper' + +RSpec.describe CreateSingleUserTeamJob, type: :job do + include ActiveJob::TestHelper + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let!(:user) { create(:user, :with_openstack_account, login: "bilbo") } + let(:changes) { {} } + let(:cloud_service_config) { create(:cloud_service_config) } + + subject(:job) { + CreateSingleUserTeamJob.perform_now(user, cloud_service_config) + } + + before(:each) do + clear_enqueued_jobs + clear_performed_jobs + end + + it "creates a single user team with the user's username" do + expect { subject }.to change(Team, :count).by(1) + team = Team.last + expect(team.name).to eq "bilbo_team" + expect(team.single_user).to eq true + end + + it "assigns user as team admin" do + expect { subject }.to change(TeamRole, :count).by(1) + role = TeamRole.last + team = Team.last + expect(role.user).to eq user + expect(role.team).to eq team + expect(role.role).to eq 'admin' + end + + it "rolls back creation of team if user assignment fails" do + user.root = true + expect { subject rescue nil }.to not_change(Team, :count).and not_change(TeamRole, :count) + + expect(CreateTeamThenRoleJob).not_to have_been_enqueued + end + + it "enqueues creation of a team in openstack" do + subject + expect(CreateTeamThenRoleJob).to have_been_enqueued.with(Team.last, TeamRole.last, cloud_service_config) + end + + it "does not enqueue creation of team in openstack if unsuccessful" do + create(:team, name: "bilbo_team") + expect { subject rescue nil }.not_to change(Team, :count) + expect(CreateTeamThenRoleJob).not_to have_been_enqueued + end + +end diff --git a/spec/jobs/create_team_job_spec.rb b/spec/jobs/create_team_job_spec.rb new file mode 100644 index 000000000..bc37fbb4d --- /dev/null +++ b/spec/jobs/create_team_job_spec.rb @@ -0,0 +1,22 @@ +require 'rails_helper' + +RSpec.describe CreateTeamJob, type: :job do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:team) { create(:team) } + + subject(:job_runner) { + CreateTeamJob::Runner.new(team: team, cloud_service_config: cloud_service_config, test_stubs: stubs) +} + + include_examples 'creating team job' + + describe "skipping deleted teams" do + let(:team) { create(:team, deleted_at: Time.current) } + + it "skips teams which have already been deleted" do + expect(described_class::Runner).not_to receive(:new) + described_class.perform_now(team, cloud_service_config, test_stubs: stubs) + end + end +end diff --git a/spec/jobs/create_team_role_job_spec.rb b/spec/jobs/create_team_role_job_spec.rb new file mode 100644 index 000000000..925657cb1 --- /dev/null +++ b/spec/jobs/create_team_role_job_spec.rb @@ -0,0 +1,90 @@ +require 'rails_helper' + +RSpec.describe CreateTeamRoleJob, type: :job do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let!(:team_role) { build(:team_role, role: "member") } + let(:new_role) { "admin" } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:create_team_roles_path) { "/team_role" } + let(:expected_url) { + "#{cloud_service_config.user_handler_base_url}#{create_team_roles_path}" + } + + subject { CreateTeamRoleJob::Runner.new(cloud_service_config: cloud_service_config, team_role: team_role) } + + describe "url" do + subject { super().send(:url) } + + it "uses the correct ip, port and path" do + expect(subject).to eq expected_url + end + end + + describe "body" do + subject { super().send(:body).with_indifferent_access } + + it "contains the admin's cloud env credentials" do + expect(subject[:cloud_env]).to eq({ + "auth_url" => cloud_service_config.internal_auth_url, + "user_id" => cloud_service_config.admin_user_id, + "password" => cloud_service_config.admin_foreign_password, + "project_id" => cloud_service_config.admin_project_id, + }) + end + + it "contains the team role's details" do + expect(subject[:team_role]).to be_a Hash + expect(subject[:team_role][:user_id]).to eq team_role.user.cloud_user_id + expect(subject[:team_role][:project_id]).to eq team_role.team.project_id + expect(subject[:team_role][:role]).to eq team_role.role + end + end + + describe "#perform" do + include ActiveJob::TestHelper + + shared_examples "makes a request to the middleware" do + it "makes a request to the middleware" do + runner = described_class::Runner.new(team_role: team_role, cloud_service_config: cloud_service_config) + expect(described_class::Runner).to receive(:new) + .with(hash_including(team_role: team_role, cloud_service_config: cloud_service_config)) + .and_return(runner) + allow(runner).to receive(:call).and_call_original + described_class.perform_now(team_role, cloud_service_config) + expect(runner).to have_received(:call) + end + end + + context "when the request is successful" do + before(:each) do + stubs.post(expected_url) { |env| [ 204, {}, "No Content"] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "persists role" do + expect { + described_class.perform_now(team_role, cloud_service_config, test_stubs: stubs) + }.to change(TeamRole, :count).by(1) + end + end + + context "when the request is unsuccessful" do + before(:each) do + stubs.post(expected_url) { |env| [ 500, {}, {"error" => "Some error message"}] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "does not change the role" do + expect { + described_class.perform_now(team_role,cloud_service_config) + }.not_to change(TeamRole, :count) + end + end + end + + include_examples 'auth token header' +end diff --git a/spec/jobs/create_team_then_role_job_spec.rb b/spec/jobs/create_team_then_role_job_spec.rb new file mode 100644 index 000000000..20fcb05ef --- /dev/null +++ b/spec/jobs/create_team_then_role_job_spec.rb @@ -0,0 +1,55 @@ +require 'rails_helper' + +RSpec.describe CreateTeamThenRoleJob, type: :job do + include ActiveJob::TestHelper + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:team) { create(:team) } + let(:team_role) { create(:team_role, team: team) } + + subject(:job_runner) { + CreateTeamThenRoleJob::Runner.new(team: team, team_role: team_role, cloud_service_config: cloud_service_config, test_stubs: stubs) + } + + include_examples 'creating team job' + + describe "creating role on success" do + let(:team_service_path) { "/team" } + + before(:each) do + clear_enqueued_jobs + clear_performed_jobs + end + + context "when team creation request succeeds" do + let(:project_id) { SecureRandom.uuid } + let(:billing_acct_id) { SecureRandom.uuid } + let(:response_body) { + {project_id: project_id, billing_acct_id: billing_acct_id} + .stringify_keys + } + + before(:each) do + stubs.post(team_service_path) { |env| [ 201, {}, response_body ] } + end + + it "enqueues job to create role" do + subject.call + expect(CreateTeamRoleJob).to have_been_enqueued.with(team_role, cloud_service_config) + end + end + + context "when team creation request fails" do + let(:response_body) { {} } + + before(:each) do + stubs.post(team_service_path) { |env| [ 201, {}, response_body ] } + end + + it "does not enqueue role creation" do + subject.call rescue nil + expect(CreateTeamRoleJob).not_to have_been_enqueued + end + end + end +end diff --git a/spec/jobs/delete_key_pair_job_spec.rb b/spec/jobs/delete_key_pair_job_spec.rb index 4872b8ed1..d61115b6a 100644 --- a/spec/jobs/delete_key_pair_job_spec.rb +++ b/spec/jobs/delete_key_pair_job_spec.rb @@ -3,9 +3,13 @@ RSpec.describe DeleteKeyPairJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } - let(:user) { create(:user, :with_openstack_details) } + let(:user) { create(:user, :with_openstack_account) } + let(:project_id) { Faker::Alphanumeric.alphanumeric(number: 10) } let(:path) { "#{cloud_service_config.user_handler_base_url}/key_pairs" } - subject { DeleteKeyPairJob::Runner.new(key_pair_name: "my_lovely_key_pair", cloud_service_config: cloud_service_config, user: user) } + subject do + DeleteKeyPairJob::Runner.new(key_pair_name: "my_lovely_key_pair", cloud_service_config: cloud_service_config, + user: user, project_id: project_id) + end describe "url" do before(:each) do @@ -31,7 +35,7 @@ class << subject end it "returns a successful result" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs) expect(result).to be_success end end @@ -42,12 +46,12 @@ class << subject end it "returns an unsuccessful result" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs) expect(result.error_message).to eq "the server responded with status 404" end end @@ -59,12 +63,12 @@ class << subject end it "returns an unsuccessful result" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs) expect(result.error_message).to eq "Something happened" end end @@ -76,12 +80,12 @@ class << subject let(:timeout) { 0.1 } it "returns an unsuccessful result" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs, timeout: timeout) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now("my_lovely_key_pair", cloud_service_config, user, project_id, test_stubs: stubs, timeout: timeout) expect(result.error_message).to eq "execution expired" end end @@ -99,7 +103,7 @@ class << subject "auth_url" => cloud_service_config.internal_auth_url, "user_id" => user.cloud_user_id, "password" => user.foreign_password, - "project_id" => user.project_id + "project_id" => project_id }) end end diff --git a/spec/jobs/delete_team_job_spec.rb b/spec/jobs/delete_team_job_spec.rb new file mode 100644 index 000000000..8c23bbe58 --- /dev/null +++ b/spec/jobs/delete_team_job_spec.rb @@ -0,0 +1,118 @@ +require 'rails_helper' + +RSpec.describe DeleteTeamJob, type: :job do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let!(:team) { create(:team, :with_openstack_details) } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:delete_team_path) { "/team" } + let(:expected_url) { + "#{cloud_service_config.user_handler_base_url}#{delete_team_path}" + } + + subject { DeleteTeamJob::Runner.new(cloud_service_config: cloud_service_config, team: team) } + + describe "url" do + subject { super().send(:url) } + + it "uses the correct ip, port and path" do + expect(subject).to eq expected_url + end + end + + describe "body" do + subject { super().send(:body).with_indifferent_access } + + it "contains the admin's cloud env credentials" do + expect(subject[:cloud_env]).to eq({ + "auth_url" => cloud_service_config.internal_auth_url, + "user_id" => cloud_service_config.admin_user_id, + "password" => cloud_service_config.admin_foreign_password, + "project_id" => cloud_service_config.admin_project_id, + }) + end + + it "contains the team's cloud env and billing ids" do + expect(subject[:team_info]).to be_a Hash + expect(subject[:team_info][:project_id]).to eq team.project_id + expect(subject[:team_info][:billing_acct_id]).to eq team.billing_acct_id + end + end + + describe "#perform" do + include ActiveJob::TestHelper + + context "when team does not have openstack" do + let!(:team) { create(:team) } + + it "does not make a request to the middleware" do + expect(described_class::Runner).not_to receive(:new) + described_class.perform_now(team, cloud_service_config, test_stubs: stubs) + end + + it "destroys the team" do + expect { + described_class.perform_now(team, cloud_service_config, test_stubs: stubs) + }.to change(Team, :count).by(-1) + end + end + + context "when team has openstack details" do + + shared_examples "makes a request to the middleware" do + it "makes a request to the middleware" do + runner = described_class::Runner.new(team: team, cloud_service_config: cloud_service_config) + expect(described_class::Runner).to receive(:new) + .with(hash_including(team: team, cloud_service_config: cloud_service_config)) + .and_return(runner) + allow(runner).to receive(:call).and_call_original + described_class.perform_now(team, cloud_service_config) + expect(runner).to have_received(:call) + end + end + + context "when the request is successful" do + before(:each) do + stubs.delete(expected_url) { |env| [ 204, {}, "No Content"] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "destroys the team" do + expect { + described_class.perform_now(team, cloud_service_config, test_stubs: stubs) + }.to change(Team, :count).by(-1) + end + end + + context "when the request is unsuccessful" do + before(:each) do + stubs.delete(expected_url) { |env| [ 500, {}, {"error" => "Some error message"}] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "does not destroy the team" do + expect { + described_class.perform_now(team, cloud_service_config) + }.not_to change(Team, :count) + end + + it "reschedules the job" do + perform_enqueued_jobs do + begin + described_class.perform_later(team, cloud_service_config) + rescue ::Faraday::Error + # We expect a ::Faraday::Error to be raised here, when the last retried job fails. + end + end + expect(DeleteTeamJob::RETRY_ATTEMPTS).to be > 1 + expect(DeleteTeamJob).to have_been_performed.exactly(DeleteTeamJob::RETRY_ATTEMPTS) + end + end + end + end + + include_examples 'auth token header' +end diff --git a/spec/jobs/delete_team_role_job_spec.rb b/spec/jobs/delete_team_role_job_spec.rb new file mode 100644 index 000000000..145b33870 --- /dev/null +++ b/spec/jobs/delete_team_role_job_spec.rb @@ -0,0 +1,100 @@ +require 'rails_helper' + +RSpec.describe DeleteTeamRoleJob, type: :job do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let!(:team_role) { create(:team_role, role: "member") } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:delete_team_role_path) { "/team_role" } + let(:path) { "#{cloud_service_config.user_handler_base_url}#{delete_team_role_path}" } + + subject { DeleteTeamRoleJob::Runner.new(cloud_service_config: cloud_service_config, team_role: team_role) } + + describe "url" do + before(:each) do + class << subject + public :connection + public :path + end + end + + it "uses the ip and port given in the config" do + expect(subject.connection.url_prefix.to_s).to eq "#{cloud_service_config.user_handler_base_url}/" + end + end + + describe "path" do + subject { super().send(:path) } + + it "uses the path" do + expect(subject).to eq delete_team_role_path + end + end + + describe "body" do + subject { super().send(:body).with_indifferent_access } + + it "contains the admin's cloud env credentials" do + expect(subject[:cloud_env]).to eq({ + "auth_url" => cloud_service_config.internal_auth_url, + "user_id" => cloud_service_config.admin_user_id, + "password" => cloud_service_config.admin_foreign_password, + "project_id" => cloud_service_config.admin_project_id, + }) + end + + it "contains the team role's details" do + expect(subject[:team_role]).to be_a Hash + expect(subject[:team_role][:user_id]).to eq team_role.user.cloud_user_id + expect(subject[:team_role][:project_id]).to eq team_role.team.project_id + expect(subject[:team_role][:role]).to eq team_role.role + end + end + + describe "#perform" do + include ActiveJob::TestHelper + + shared_examples "makes a request to the middleware" do + it "makes a request to the middleware" do + runner = described_class::Runner.new(team_role: team_role, cloud_service_config: cloud_service_config) + expect(described_class::Runner).to receive(:new) + .with(hash_including(team_role: team_role, cloud_service_config: cloud_service_config)) + .and_return(runner) + allow(runner).to receive(:call).and_call_original + described_class.perform_now(team_role, cloud_service_config) + expect(runner).to have_received(:call) + end + end + + context "when the request is successful" do + before(:each) do + stubs.delete(path) { |env| [ 204, {}, "No Content"] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "deletes role" do + expect { + described_class.perform_now(team_role, cloud_service_config, test_stubs: stubs) + }.to change(TeamRole, :count).by(-1) + end + end + + context "when the request is unsuccessful" do + before(:each) do + stubs.delete(path) { |env| [ 500, {}, {"error" => "Some error message"}] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "does not change the role" do + expect { + described_class.perform_now(team_role,cloud_service_config) + }.not_to change(TeamRole, :count) + end + end + end + + include_examples 'auth token header' +end diff --git a/spec/jobs/get_draft_invoice_job_spec.rb b/spec/jobs/get_draft_invoice_job_spec.rb index e6e186b7d..a4b6d26b9 100644 --- a/spec/jobs/get_draft_invoice_job_spec.rb +++ b/spec/jobs/get_draft_invoice_job_spec.rb @@ -4,15 +4,15 @@ let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } - let(:user) { create(:user, :with_openstack_details) } + let(:team) { create(:team, :with_openstack_details) } subject(:job_runner) { - described_class::Runner.new(user: user, cloud_service_config: cloud_service_config, test_stubs: stubs) + described_class::Runner.new(team: team, cloud_service_config: cloud_service_config, test_stubs: stubs) } - let(:user_invoice_path) { "/get_draft_invoice" } + let(:team_invoice_path) { "/get_draft_invoice" } let(:expected_url) { - "#{cloud_service_config.user_handler_base_url}#{user_invoice_path}" + "#{cloud_service_config.user_handler_base_url}#{team_invoice_path}" } describe "url" do @@ -28,7 +28,7 @@ it "contains invoice config" do expect(subject[:invoice]).to eq({ - "billing_account_id" => user.billing_acct_id, + "billing_acct_id" => team.billing_acct_id, "target_date" => "#{Date.today.year}-#{"%02d" % Date.today.month}-#{"%02d" % Date.today.day}", }) end @@ -42,7 +42,7 @@ let(:draft_invoice) { { - account_id: user.billing_acct_id, + account_id: team.billing_acct_id, amount: 1, balance: 2, credit_adj: 0, @@ -57,14 +57,14 @@ } it "returns a successful result" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs) expect(result).to be_success end it "contains the invoice document in the result" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs) expected_invoice = Invoice.new( - account: user, + account: team, amount: 1, balance: 2, credit_adj: 0, @@ -86,12 +86,12 @@ end it "returns an unsuccessful result" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs) expect(result.error_message).to eq "the server responded with status 404" end end @@ -103,12 +103,12 @@ let(:timeout) { 0.1 } it "returns an unsuccessful result" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs, timeout: timeout) expect(result).not_to be_success end it "returns a sensible error_message" do - result = described_class.perform_now(cloud_service_config, user, test_stubs: stubs, timeout: timeout) + result = described_class.perform_now(cloud_service_config, team, test_stubs: stubs, timeout: timeout) expect(result.error_message).to eq "execution expired" end end diff --git a/spec/jobs/request_status_change_job_spec.rb b/spec/jobs/request_status_change_job_spec.rb index 3567a1bd9..7b8b61035 100644 --- a/spec/jobs/request_status_change_job_spec.rb +++ b/spec/jobs/request_status_change_job_spec.rb @@ -3,13 +3,13 @@ RSpec.describe RequestStatusChangeJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config, admin_project_id: Faker::Alphanumeric.alphanumeric(number: 10), admin_user_id: Faker::Alphanumeric.alphanumeric(number: 10)) } - let(:customer_user) { create(:user, project_id: Faker::Alphanumeric.alphanumeric(number: 10), cloud_user_id: Faker::Alphanumeric.alphanumeric(number: 10)) } + let(:customer_user) { create(:user, :with_openstack_account) } let(:admin) { create(:user, :admin) } let(:user) { customer_user } let(:device) { create(:device, chassis: chassis, status: "ACTIVE") } let(:chassis) { create(:chassis, location: location, template: device_template) } let(:location) { create(:location, rack: rack) } - let(:rack) { create(:rack, user: user, template: rack_template, status: "ACTIVE") } + let(:rack) { create(:rack, template: rack_template, status: "ACTIVE") } let(:device_template) { create(:template, :device_template) } let(:rack_template) { create(:template, :rack_template) } let(:action) { "destroy" } @@ -67,7 +67,7 @@ "auth_url" => cloud_service_config.internal_auth_url, "user_id" => user.cloud_user_id, "password" => user.foreign_password, - "project_id" => user.project_id + "project_id" => rack.team.project_id }) end end diff --git a/spec/jobs/update_team_role_job_spec.rb b/spec/jobs/update_team_role_job_spec.rb new file mode 100644 index 000000000..fee898f9d --- /dev/null +++ b/spec/jobs/update_team_role_job_spec.rb @@ -0,0 +1,89 @@ +require 'rails_helper' + +RSpec.describe UpdateTeamRoleJob, type: :job do + let(:stubs) { Faraday::Adapter::Test::Stubs.new } + let!(:team_role) { create(:team_role, role: "member") } + let(:new_role) { "admin" } + let(:cloud_service_config) { create(:cloud_service_config) } + let(:update_roles_path) { "/team_role" } + let(:expected_url) { + "#{cloud_service_config.user_handler_base_url}#{update_roles_path}" + } + + subject { UpdateTeamRoleJob::Runner.new(cloud_service_config: cloud_service_config, team_role: team_role, new_role: new_role) } + + describe "url" do + subject { super().send(:url) } + + it "uses the correct ip, port and path" do + expect(subject).to eq expected_url + end + end + + describe "body" do + subject { super().send(:body).with_indifferent_access } + + it "contains the admin's cloud env credentials" do + expect(subject[:cloud_env]).to eq({ + "auth_url" => cloud_service_config.internal_auth_url, + "user_id" => cloud_service_config.admin_user_id, + "password" => cloud_service_config.admin_foreign_password, + "project_id" => cloud_service_config.admin_project_id, + }) + end + + it "contains the user's cloud env and billing ids" do + expect(subject[:team_role]).to be_a Hash + expect(subject[:team_role][:user_id]).to eq team_role.user.cloud_user_id + expect(subject[:team_role][:project_id]).to eq team_role.team.project_id + end + end + + describe "#perform" do + include ActiveJob::TestHelper + + shared_examples "makes a request to the middleware" do + it "makes a request to the middleware" do + runner = described_class::Runner.new(team_role: team_role, new_role: new_role, cloud_service_config: cloud_service_config) + expect(described_class::Runner).to receive(:new) + .with(hash_including(team_role: team_role, new_role: new_role, cloud_service_config: cloud_service_config)) + .and_return(runner) + allow(runner).to receive(:call).and_call_original + described_class.perform_now(team_role, new_role, cloud_service_config) + expect(runner).to have_received(:call) + end + end + + context "when the request is successful" do + before(:each) do + stubs.patch(expected_url) { |env| [ 204, {}, "No Content"] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "updates role" do + expect { + described_class.perform_now(team_role, new_role, cloud_service_config, test_stubs: stubs) + }.to change(team_role, :role) + end + end + + context "when the request is unsuccessful" do + before(:each) do + stubs.patch(expected_url) { |env| [ 500, {}, {"error" => "Some error message"}] } + allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) + end + + include_examples "makes a request to the middleware" + + it "does not change the role" do + expect { + described_class.perform_now(team_role, new_role ,cloud_service_config) + }.not_to change(team_role, :role) + end + end + end + + include_examples 'auth token header' +end diff --git a/spec/jobs/user_deletion_job_spec.rb b/spec/jobs/user_deletion_job_spec.rb index 6989b299c..7c86f19c2 100644 --- a/spec/jobs/user_deletion_job_spec.rb +++ b/spec/jobs/user_deletion_job_spec.rb @@ -2,9 +2,9 @@ RSpec.describe UserDeletionJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } - let!(:user) { create(:user, :with_openstack_details) } + let!(:user) { create(:user, :with_openstack_account) } let(:cloud_service_config) { create(:cloud_service_config) } - let(:delete_user_path) { "/delete_user" } + let(:delete_user_path) { "/user" } let(:expected_url) { "#{cloud_service_config.user_handler_base_url}#{delete_user_path}" } @@ -31,11 +31,9 @@ }) end - it "contains the user's cloud env and billing ids" do + it "contains the user's cloud env id" do expect(subject[:user_info]).to be_a Hash expect(subject[:user_info][:cloud_user_id]).to eq user.cloud_user_id - expect(subject[:user_info][:project_id]).to eq user.project_id - expect(subject[:user_info][:billing_acct_id]).to eq user.billing_acct_id end end diff --git a/spec/jobs/user_signup_job_spec.rb b/spec/jobs/user_signup_job_spec.rb index ea50ae540..a16f5446a 100644 --- a/spec/jobs/user_signup_job_spec.rb +++ b/spec/jobs/user_signup_job_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' RSpec.describe UserSignupJob, type: :job do + include ActiveJob::TestHelper let(:stubs) { Faraday::Adapter::Test::Stubs.new } let(:cloud_service_config) { create(:cloud_service_config) } let(:user) { create(:user) } @@ -10,7 +11,7 @@ } describe "url" do - let(:user_service_path) { "/create_user_project" } + let(:user_service_path) { "/user" } subject { super().send(:url) } @@ -34,40 +35,6 @@ expect(subject["email"]).to eq user.email end - context "when the user has a project id" do - let(:user) { create(:user, project_id: Faker::Internet.uuid) } - - it "contains the user's project id" do - expect(user.project_id).not_to be_nil - expect(subject["project_id"]).to eq user.project_id - end - end - - context "when the user does not have a project id" do - it "does not contain the user's project id" do - expect(user.project_id).to be_nil - expect(subject).not_to have_key "project_id" - expect(subject).not_to have_key :project_id - end - end - - context "when the user has a cloud user id" do - let(:user) { create(:user, cloud_user_id: Faker::Internet.uuid) } - - it "contains the user's cloud user id" do - expect(user.cloud_user_id).not_to be_nil - expect(subject["cloud_user_id"]).to eq user.cloud_user_id - end - end - - context "when the user does not have a cloud user id" do - it "does not contain the user's cloud user id" do - expect(user.cloud_user_id).to be_nil - expect(subject).not_to have_key "cloud_user_id" - expect(subject).not_to have_key :cloud_user_id - end - end - it "contains the correct cloud environment config" do expect(subject[:cloud_env]).to eq({ "auth_url" => cloud_service_config.internal_auth_url, @@ -79,7 +46,7 @@ end describe "updating the user's details from the response" do - let(:user_service_path) { "/create_user_project" } + let(:user_service_path) { "/user" } context "when response does not contain expected fields" do let(:response_body) { {} } @@ -95,19 +62,18 @@ expect { subject.call rescue nil }.not_to change(user, :cloud_user_id).from(nil) end - it "does not update the project_id" do - expect { subject.call rescue nil }.not_to change(user, :project_id).from(nil) + it "does not enqueue user team creation" do + clear_enqueued_jobs + clear_performed_jobs + + subject.call rescue nil + expect(CreateSingleUserTeamJob).not_to have_been_enqueued end end context "when response contains expected fields" do let(:cloud_user_id) { SecureRandom.uuid } - let(:project_id) { SecureRandom.uuid } - let(:billing_acct_id) { SecureRandom.uuid } - let(:response_body) { - {user_id: cloud_user_id, project_id: project_id, billing_account_id: billing_acct_id} - .stringify_keys - } + let(:response_body) { { "user_cloud_id" => cloud_user_id } } before(:each) do stubs.post(user_service_path) { |env| [ 201, {}, response_body ] } @@ -116,8 +82,14 @@ it "updates the user's cloud_user_id, project_id and billing_acct_id" do expect { subject.call } .to change(user, :cloud_user_id).from(nil).to(cloud_user_id) - .and change(user, :project_id).from(nil).to(project_id) - .and change(user, :billing_acct_id).from(nil).to(billing_acct_id) + end + + it "enqueues user team creation" do + clear_enqueued_jobs + clear_performed_jobs + + subject.call + expect(CreateSingleUserTeamJob).to have_been_enqueued.with(user, cloud_service_config) end end end diff --git a/spec/jobs/user_update_job_spec.rb b/spec/jobs/user_update_job_spec.rb index 2962eef64..6e1c81211 100644 --- a/spec/jobs/user_update_job_spec.rb +++ b/spec/jobs/user_update_job_spec.rb @@ -2,10 +2,10 @@ RSpec.describe UserUpdateJob, type: :job do let(:stubs) { Faraday::Adapter::Test::Stubs.new } - let!(:user) { create(:user, :with_openstack_details) } + let!(:user) { create(:user, :with_openstack_account) } let(:changes) { {} } let(:cloud_service_config) { create(:cloud_service_config) } - let(:update_users_path) { "/change_user_details" } + let(:update_users_path) { "/user" } let(:expected_url) { "#{cloud_service_config.user_handler_base_url}#{update_users_path}" } @@ -32,10 +32,9 @@ }) end - it "contains the user's cloud env and billing ids" do + it "contains the user's cloud env id" do expect(subject[:user_info]).to be_a Hash expect(subject[:user_info][:cloud_user_id]).to eq user.cloud_user_id - expect(subject[:user_info][:billing_acct_id]).to eq user.billing_acct_id end [ @@ -91,7 +90,7 @@ context "when the request is successful" do before(:each) do - stubs.post(expected_url) { |env| [ 204, {}, "No Content"] } + stubs.patch(expected_url) { |env| [ 204, {}, "No Content"] } allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) end @@ -106,7 +105,7 @@ context "when the request is unsuccessful" do before(:each) do - stubs.post(expected_url) { |env| [ 500, {}, {"error" => "Some error message"}] } + stubs.patch(expected_url) { |env| [ 500, {}, {"error" => "Some error message"}] } allow_any_instance_of(described_class::Runner).to receive(:test_stubs).and_return(stubs) end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb deleted file mode 100644 index efc09c770..000000000 --- a/spec/models/ability_spec.rb +++ /dev/null @@ -1,26 +0,0 @@ -require 'rails_helper' - -RSpec.describe Ability, type: :model do - let!(:user) { create(:user) } - - describe "#enough_credits_to_create_cluster?" do - it "is false if zero" do - user.credits = 0 - expect(Ability.new(user).enough_credits_to_create_cluster?).to eq false - end - - it "is false if below set requirement" do - Rails.application.config.cluster_credit_requirement = 10 - user.credits = 9 - expect(Ability.new(user).enough_credits_to_create_cluster?).to eq false - end - - it "is true if above or equal to requirement" do - Rails.application.config.cluster_credit_requirement = 10 - user.credits = 10 - expect(Ability.new(user).enough_credits_to_create_cluster?).to eq true - user.credits = 11 - expect(Ability.new(user).enough_credits_to_create_cluster?).to eq true - end - end -end diff --git a/spec/models/cluster_spec.rb b/spec/models/cluster_spec.rb index 60a312916..b888cd3ec 100644 --- a/spec/models/cluster_spec.rb +++ b/spec/models/cluster_spec.rb @@ -22,4 +22,14 @@ cluster_params["clustername"] = nil expect(subject).to have_error("Cluster name", "can't be blank") end + + it "is not valid without a team" do + subject.team = nil + expect(subject).to have_error(:team, :blank) + end + + it "is not valid if team has insufficient credits" do + subject.team.update(credits: 0) + expect(subject).to have_error(:team, "Has insufficient credits to launch a cluster") + end end diff --git a/spec/models/credit_deposit_spec.rb b/spec/models/credit_deposit_spec.rb index 2720634f7..98a5ac421 100644 --- a/spec/models/credit_deposit_spec.rb +++ b/spec/models/credit_deposit_spec.rb @@ -21,4 +21,19 @@ subject.amount = 0 expect(subject).to have_error(:amount, :greater_than) end + + it "is not valid without a team" do + subject.team = nil + expect(subject).to have_error(:team, :blank) + end + + it "is not valid if team has no project id" do + subject.team.project_id = nil + expect(subject).to have_error(:team, "must have a project id") + end + + it "is not valid if team has no billing account id" do + subject.team.billing_acct_id = nil + expect(subject).to have_error(:team, "must have a billing account id") + end end diff --git a/spec/models/device/network_details_spec.rb b/spec/models/device/network_details_spec.rb index ed06170e1..751948a1d 100644 --- a/spec/models/device/network_details_spec.rb +++ b/spec/models/device/network_details_spec.rb @@ -1,9 +1,9 @@ require 'rails_helper' RSpec.describe Device::NetworkDetails, type: :model do - let(:user) { create(:user) } + let(:team) { create(:team) } let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: user, template: rack_template) } + let!(:rack) { create(:rack, team: team, template: rack_template) } let(:location) { create(:location, rack: rack) } let(:chassis) { create(:chassis, location: location, template: template) } diff --git a/spec/models/device/volume_details_spec.rb b/spec/models/device/volume_details_spec.rb index 99d0cf702..fe504c043 100644 --- a/spec/models/device/volume_details_spec.rb +++ b/spec/models/device/volume_details_spec.rb @@ -1,9 +1,9 @@ require 'rails_helper' RSpec.describe Device::VolumeDetails, type: :model do - let(:user) { create(:user) } + let(:team) { create(:team) } let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: user, template: rack_template) } + let!(:rack) { create(:rack, team: team, template: rack_template) } let(:location) { create(:location, rack: rack) } let(:chassis) { create(:chassis, location: location, template: template) } diff --git a/spec/models/device_spec.rb b/spec/models/device_spec.rb index 4b0915f94..9c41def82 100644 --- a/spec/models/device_spec.rb +++ b/spec/models/device_spec.rb @@ -6,8 +6,8 @@ let(:device) { create(:device, chassis: chassis) } let(:chassis) { create(:chassis, location: location, template: device_template) } let(:location) { create(:location, rack: rack) } - let!(:rack) { create(:rack, user: user, template: rack_template) } - let(:user) { create(:user) } + let!(:rack) { create(:rack, template: rack_template) } + let(:user) { create(:user, :as_team_member, team: rack.team) } let(:device_template) { create(:template, :device_template) } describe 'validations' do @@ -55,7 +55,7 @@ end it "can duplicate names for devices in other racks" do - new_rack = create(:rack, user: user, template: rack_template) + new_rack = create(:rack, template: rack_template) new_location = create(:location, rack: new_rack) new_chassis = create(:chassis, location: new_location, template: device_template) new_device = build(:device, chassis: new_chassis, name: subject.name) @@ -100,7 +100,7 @@ expect(data["action"]).to eq action rack_data = data["rack"] expect(rack_data.present?).to be true - expect(rack_data["owner"]["id"]).to eq rack.user.id.to_s + expect(rack_data["owner"]["id"]).to eq rack.team.id.to_s expect(rack_data["template"]["name"]).to eq rack.template.name expect(rack_data["id"]).to eq rack.id.to_s expect(rack_data["name"]).to eq rack.name diff --git a/spec/models/hw_rack_spec.rb b/spec/models/hw_rack_spec.rb index f0e751de0..a7b84e997 100644 --- a/spec/models/hw_rack_spec.rb +++ b/spec/models/hw_rack_spec.rb @@ -3,14 +3,14 @@ RSpec.describe HwRack, type: :model do subject { rack } let!(:template) { create(:template, :rack_template) } - let(:rack) { create(:rack, user: user, template: template) } - let!(:user) { create(:user) } + let(:rack) { create(:rack, team: team, template: template) } + let!(:team) { create(:team) } describe 'validations' do it "is valid with valid attributes" do rack = described_class.new( template: template, - user: user, + team: team, status: 'IN_PROGRESS', cost: 99.99, order_id: 42, @@ -48,25 +48,25 @@ expect(subject).to have_error(:template, :blank) end - it "is not valid without a user" do - subject.user = nil - expect(subject).to have_error(:user, :blank) + it "is not valid without a team" do + subject.team = nil + expect(subject).to have_error(:team, :blank) end it "must have a unique name" do - new_rack = build(:rack, user: user, template: template, name: subject.name) + new_rack = build(:rack, team: team, template: template, name: subject.name) expect(new_rack).to have_error(:name, :taken) end - it "can duplicate names for racks belonging to other users" do - new_user = create(:user) - new_rack = build(:rack, user: new_user, template: template, name: subject.name) + it "can duplicate names for racks belonging to other teams" do + new_team = create(:team) + new_rack = build(:rack, team: new_team, template: template, name: subject.name) expect(new_rack).not_to have_error(:name, :taken) end it "must be higher than highest node" do # Changing the height of a rack is only allowed if the new height is - # sufficiently large to accomodate all of the nodes it contains. + # sufficiently large to accommodate all of the nodes it contains. skip "implement this when we have device factories et al" end @@ -92,7 +92,7 @@ end it "must have a unique order id" do - new_rack = build(:rack, user: user, template: template, order_id: subject.order_id) + new_rack = build(:rack, team: team, template: template, order_id: subject.order_id) expect(new_rack).to have_error(:order_id, :taken) end end @@ -103,59 +103,61 @@ context "when there are no other racks" do it "defaults height to 42" do - rack = HwRack.new(u_height: nil, user: user) + rack = HwRack.new(u_height: nil, team: team) expect(rack.u_height).to eq 42 end it "defaults name to Rack-1" do - rack = HwRack.new(user: user) + rack = HwRack.new(team: team) expect(rack.name).to eq "Rack-1" end end - context "when there are other racks for other users" do - let(:other_user) { create(:user) } + context "when there are other racks for other teams" do + let(:other_team) { create(:team) } let!(:existing_rack) { - create(:rack, u_height: 24, name: 'MyRack-2', template: template, user: other_user) + create(:rack, u_height: 24, name: 'MyRack-2', template: template, team: other_team) } it "defaults height to 42" do - rack = HwRack.new(u_height: nil, user: user) + rack = HwRack.new(u_height: nil, team: team) expect(rack.u_height).to eq 42 end it "defaults name to Rack-1" do - rack = HwRack.new(user: user) + rack = HwRack.new(team: team) expect(rack.name).to eq "Rack-1" end end - context "when there are other racks for this user" do + context "when there are other racks for this team" do let!(:existing_rack) { - create(:rack, u_height: 24, name: 'MyRack-2', template: template, user: user) + create(:rack, u_height: 24, name: 'MyRack-2', template: template, team: team) } it "defaults height to existing racks height" do - rack = HwRack.new(u_height: nil, user: user) + rack = HwRack.new(u_height: nil, team: team) expect(rack.u_height).to eq 24 end it "defaults name to increment of existing racks name" do - rack = HwRack.new(user: user) + rack = HwRack.new(team: team) expect(rack.name).to eq 'MyRack-3' end end end describe "broadcast changes" do + let!(:user) { create(:user, :as_team_member, team: team) } + shared_examples 'rack details' do it 'broadcasts rack details' do expect { subject }.to have_broadcasted_to(user).from_channel(InteractiveRackViewChannel).with { |data| expect(data["action"]).to eq action rack_data = data["rack"] expect(rack_data.present?).to be true - expect(rack_data["owner"]["id"]).to eq rack.user.id.to_s + expect(rack_data["owner"]["id"]).to eq rack.team.id.to_s expect(rack_data["template"]["name"]).to eq rack.template.name expect(rack_data["id"]).to eq rack.id.to_s expect(rack_data["name"]).to eq rack.name @@ -173,7 +175,7 @@ context 'updated' do let(:action) { "modified" } - let!(:rack) { create(:rack, user: user, template: template) } + let!(:rack) { create(:rack, team: team, template: template) } subject do rack.name = "new_name" rack.save! @@ -183,7 +185,7 @@ end context 'deleted' do - let!(:rack) { create(:rack, user: user, template: template) } + let!(:rack) { create(:rack, team: team, template: template) } subject { rack.destroy! } it 'broadcasts deleted rack' do diff --git a/spec/models/key_pair_spec.rb b/spec/models/key_pair_spec.rb index e9e248d49..0ab4b856a 100644 --- a/spec/models/key_pair_spec.rb +++ b/spec/models/key_pair_spec.rb @@ -1,7 +1,7 @@ require 'rails_helper' RSpec.describe KeyPair, type: :model do - let(:user) { create(:user, :with_openstack_details) } + let(:user) { create(:user, :with_openstack_account) } subject { build(:key_pair, user: user, fingerprint: "abc") } it "is valid with valid attributes" do diff --git a/spec/models/team_role_spec.rb b/spec/models/team_role_spec.rb new file mode 100644 index 000000000..eaf356371 --- /dev/null +++ b/spec/models/team_role_spec.rb @@ -0,0 +1,128 @@ +require 'rails_helper' + +RSpec.describe TeamRole, type: :model do + subject { team_role } + let(:team_role) { create(:team_role, role: "member", team: team) } + let(:team) { create(:team) } + let(:user) { create(:user) } + + describe 'validations' do + it "is valid with valid attributes" do + role = described_class.new( + user: user, + team: team, + role: "member" + ) + expect(role).to be_valid + end + + describe "user" do + it "is not valid without a user" do + subject.user = nil + expect(subject).to have_error(:user, :blank) + end + + it "is not valid if user a super admin" do + subject.user.root = true + expect(subject).to have_error(:user, "must not be super admin") + end + end + + describe "team" do + it "is not valid without a team" do + subject.team = nil + expect(subject).to have_error(:team, :blank) + end + + it "must be a unique user team combination" do + new_role = build(:team_role, team: subject.team, user: subject.user) + expect(new_role).to have_error(:user_id, :taken) + new_role.team = create(:team) + expect(new_role).to be_valid + end + + it "allows multiple roles for a regular team" do + new_role = build(:team_role, team: subject.team) + expect(new_role).to be_valid + end + + it "does not allow multiple roles for a single user team" do + subject.team.single_user = true + subject.team.save! + new_role = build(:team_role, team: subject.team) + expect(new_role).to have_error(:team, "is a single user team and already has an assigned user") + expect(subject).to be_valid + end + end + + describe "role" do + it "is not valid without a role" do + subject.role = nil + expect(subject).to have_error(:role, :blank) + end + + it "it not valid with an unsupported role" do + subject.role = "viewer" + expect(subject).to have_error(:role, :inclusion) + end + end + + it "must be a unique user team combination" do + new_role = build(:team_role, team: subject.team, user: subject.user) + expect(new_role).to have_error(:user_id, :taken) + new_role.team = create(:team) + expect(new_role).to be_valid + end + end + + describe "broadcast changes" do + let!(:template) { create(:template, :rack_template) } + let!(:rack) { create(:rack, team: team, template: template) } + let(:user) { team_role.user } + + shared_examples 'rack details' do + it 'broadcasts rack details' do + expect { subject }.to have_broadcasted_to(user).from_channel(InteractiveRackViewChannel).with { |data| + expect(data["action"]).to eq "latest_full_data" + rack_data = data["Racks"]["Rack"][0] + expect(rack_data.present?).to be true + expect(rack_data["owner"]["id"]).to eq rack.team.id.to_s + expect(rack_data["template"]["name"]).to eq rack.template.name + expect(rack_data["id"]).to eq rack.id.to_s + expect(rack_data["name"]).to eq rack.name + expect(rack_data["cost"]).to eq "$0.00" + expect(rack_data["teamRole"]).to eq team_role.role + } + end + end + + context 'created' do + let!(:user) { create(:user) } + let(:team_role) { build(:team_role, team: team, role: "member", user: user) } + + subject { team_role.save! } + + include_examples 'rack details' + end + + context 'updated' do + subject do + team_role.role = "admin" + team_role.save! + end + + include_examples 'rack details' + end + + context 'deleted' do + subject { team_role.destroy! } + + it "broadcasts rack data, without the team's rack" do + expect { subject }.to have_broadcasted_to(user).from_channel(InteractiveRackViewChannel).with { |data| + expect(data["action"]).to eq "latest_full_data" + expect(data["Racks"]["Rack"]).to eq [] + } + end + end + end +end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb new file mode 100644 index 000000000..a0a3e39ae --- /dev/null +++ b/spec/models/team_spec.rb @@ -0,0 +1,144 @@ +require 'rails_helper' + +RSpec.describe Team, type: :model do + subject { team } + let!(:team) { create(:team) } + + describe 'validations' do + it "is valid with valid attributes" do + team = described_class.new( + name: "Hufflepuff", + cost: 99.99, + billing_period_start: Date.current, + billing_period_end: Date.current + 3.days + ) + expect(team).to be_valid + end + + describe "name" do + it "is not valid without a name" do + subject.name = nil + expect(subject).to have_error(:name, :blank) + end + + it "is not valid if too long" do + subject.name = "a" * 57 + expect(subject).to have_error(:name, :too_long) + end + + it "strips whitespace from name" do + subject.name = " space " + expect(subject.name).to eq "space" + end + + it "must be unique" do + new_team = build(:team, name: " #{subject.name}") + expect(new_team).to have_error(:name, :taken) + end + end + + describe "cost" do + it "is not valid when negative" do + subject.cost = -99 + expect(subject).to have_error(:cost, :greater_than_or_equal_to) + end + + it "is valid with no cost" do + subject.cost = nil + expect(subject).to be_valid + end + end + + describe "credits" do + it "is not valid with nil credits" do + subject.credits = nil + expect(subject).to have_error(:credits, :blank) + end + + it "must be a number" do + subject.credits = "not a number" + expect(subject).to have_error(:credits, :not_a_number) + end + end + + describe "project_id" do + it "must be unique if present" do + subject.project_id = SecureRandom.uuid + subject.save! + subject.reload + expect(subject.project_id).not_to be_nil + + new_team = build(:team, project_id: subject.project_id) + expect(new_team).to have_error(:project_id, :taken) + end + + specify "duplicate nils are ok" do + expect(subject.project_id).to be_nil + + new_team = build(:team, project_id: subject.project_id) + expect(new_team).not_to have_error(:project_id, :taken) + end + + it "strips whitespace" do + subject.project_id = " abc " + expect(subject.project_id).to eq "abc" + end + end + + describe "billing_acct_id" do + it "must be unique if present" do + subject.billing_acct_id = SecureRandom.uuid + subject.save! + subject.reload + expect(subject.billing_acct_id).not_to be_nil + + new_team = build(:team, billing_acct_id: subject.billing_acct_id) + expect(new_team).to have_error(:billing_acct_id, :taken) + end + + specify "duplicate nils are ok" do + expect(subject.billing_acct_id).to be_nil + + new_team = build(:team, billing_acct_id: subject.billing_acct_id) + expect(new_team).not_to have_error(:billing_acct_id, :taken) + end + end + + describe "billing period dates" do + it 'is not valid if has only a start or only an end' do + subject.billing_period_start = Date.current + expect(subject).to have_error(:billing_period, 'must have a start date and end date, or neither') + + subject.billing_period_end = Date.current + 2.days + expect(subject).to be_valid + + subject.billing_period_end = nil + expect(subject).to have_error(:billing_period, 'must have a start date and end date, or neither') + + subject.billing_period_start = nil + expect(subject).to be_valid + end + + it 'is not valid if end not after start' do + subject.billing_period_start = Date.current + subject.billing_period_end = Date.current + expect(subject).to have_error(:billing_period_end, :greater_than) + + subject.billing_period_end = Date.current - 2.days + expect(subject).to have_error(:billing_period_end, :greater_than) + end + + it 'is not valid if start later than today' do + subject.billing_period_start = Date.current + 1.month + subject.billing_period_end = Date.current + 2.months + expect(subject).to have_error(:billing_period_start, "must be today or earlier") + end + + it 'is not valid if end earlier than today' do + subject.billing_period_start = Date.current - 1.month + subject.billing_period_end = Date.current - 2.days + expect(subject).to have_error(:billing_period_end, "must be today or later") + end + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index c9ec0c55d..4ddd0c270 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -11,9 +11,6 @@ email: "an@email.com", login: "login", password: "password", - cost: 99.99, - billing_period_start: Date.current, - billing_period_end: Date.current + 3.days ) expect(user).to be_valid end @@ -58,49 +55,6 @@ end end - describe "cost" do - it "is not valid when negative" do - subject.cost = -99 - expect(subject).to have_error(:cost, :greater_than_or_equal_to) - end - - it "is valid with no cost" do - subject.cost = nil - expect(subject).to be_valid - end - end - - describe "credits" do - it "is not valid with nil credits" do - subject.credits = nil - expect(subject).to have_error(:credits, :blank) - end - - it "must be a number" do - subject.credits = "not a number" - expect(subject).to have_error(:credits, :not_a_number) - end - end - - describe "project_id" do - it "must be unique if present" do - user.project_id = SecureRandom.uuid - user.save! - user.reload - expect(user.project_id).not_to be_nil - - new_user = build(:user, project_id: user.project_id) - expect(new_user).to have_error(:project_id, :taken) - end - - specify "duplicate nils are ok" do - expect(user.project_id).to be_nil - - new_user = build(:user, project_id: user.project_id) - expect(new_user).not_to have_error(:project_id, :taken) - end - end - describe "cloud_user_id" do it "must be unique if present" do user.cloud_user_id = SecureRandom.uuid @@ -119,60 +73,48 @@ expect(new_user).not_to have_error(:cloud_user_id, :taken) end end + end - describe "billing_acct_id" do - it "must be unique if present" do - user.billing_acct_id = SecureRandom.uuid - user.save! - user.reload - expect(user.billing_acct_id).not_to be_nil - - new_user = build(:user, billing_acct_id: user.billing_acct_id) - expect(new_user).to have_error(:billing_acct_id, :taken) - end - - specify "duplicate nils are ok" do - expect(user.billing_acct_id).to be_nil + describe 'teams where admin' do + let!(:team) { create(:team) } - new_user = build(:user, billing_acct_id: user.billing_acct_id) - expect(new_user).not_to have_error(:billing_acct_id, :taken) + context "no team roles" do + it "returns empty" do + expect(user.teams_where_admin).to eq [] end end - describe "billing period dates" do - it 'is not valid if has only a start or only an end' do - user.billing_period_start = Date.current - expect(user).to have_error(:billing_period, 'must have a start date and end date, or neither') + context "with member role" do + let!(:role) { create(:team_role, user: user, team: team, role: "member") } - user.billing_period_end = Date.current + 2.days - expect(user).to be_valid + it "returns empty" do + expect(user.teams_where_admin).to eq [] + end - user.billing_period_end = nil - expect(user).to have_error(:billing_period, 'must have a start date and end date, or neither') + context "team has other users with roles" do + let!(:other_users_role) { create(:team_role, team: team, role: "member") } + let!(:another_users_role) { create(:team_role, team: team, role: "admin") } - user.billing_period_start = nil - expect(user).to be_valid + it "returns empty" do + expect(user.teams_where_admin).to eq [] + end end + end - it 'is not valid if end not after start' do - user.billing_period_start = Date.current - user.billing_period_end = Date.current - expect(user).to have_error(:billing_period_end, :greater_than) + context "with admin role" do + let!(:role) { create(:team_role, user: user, team: team, role: "admin") } - user.billing_period_end = Date.current - 2.days - expect(user).to have_error(:billing_period_end, :greater_than) + it "returns team" do + expect(user.teams_where_admin).to eq [team] end - it 'is not valid if start later than today' do - user.billing_period_start = Date.current + 1.month - user.billing_period_end = Date.current + 2.months - expect(user).to have_error(:billing_period_start, "must be today or earlier") - end + context "with roles in other teams" do + let!(:other_role) { create(:team_role, user: user, role: "member") } + let!(:another_role) { create(:team_role, user: user, role: "admin") } - it 'is not valid if end earlier than today' do - user.billing_period_start = Date.current - 1.month - user.billing_period_end = Date.current - 2.days - expect(user).to have_error(:billing_period_end, "must be today or later") + it "returns all teams where admin" do + expect(user.teams_where_admin.sort).to eq [team, another_role.team].sort + end end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 7258008df..4e9763812 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -39,7 +39,7 @@ end RSpec.configure do |config| # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures - config.fixture_path = "#{::Rails.root}/spec/fixtures" + config.fixture_paths = ["#{::Rails.root}/spec/fixtures"] # If you're not using ActiveRecord, or you'd prefer not to run each of your # examples within a transaction, remove the following line or assign false diff --git a/spec/requests/api/v1/data_source_maps_controller_specs.rb b/spec/requests/api/v1/data_source_maps_controller_specs.rb index e3c5900b2..cf9a1d0d9 100644 --- a/spec/requests/api/v1/data_source_maps_controller_specs.rb +++ b/spec/requests/api/v1/data_source_maps_controller_specs.rb @@ -4,7 +4,7 @@ let(:headers) { {} } let(:urls) { Rails.application.routes.url_helpers } let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: create(:user), template: rack_template) } + let!(:rack) { create(:rack, template: rack_template) } let(:device_template) { create(:template, :device_template) } describe "GET :index" do diff --git a/spec/requests/api/v1/devices_controller_spec.rb b/spec/requests/api/v1/devices_controller_spec.rb index 4e570a16f..37c7db0f6 100644 --- a/spec/requests/api/v1/devices_controller_spec.rb +++ b/spec/requests/api/v1/devices_controller_spec.rb @@ -4,7 +4,7 @@ let(:headers) { {} } let(:urls) { Rails.application.routes.url_helpers } let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: rack_owner, template: rack_template) } + let!(:rack) { create(:rack, template: rack_template) } let(:device_template) { create(:template, :device_template) } shared_examples "single device response examples" do @@ -29,13 +29,11 @@ let(:full_template_details) { false } context "when not logged in" do - let(:rack_owner) { create(:user) } include_examples "unauthorised JSON response" end - context "when logged in as admin" do + context "when logged in as super admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } context "when there are no racks" do let(:parsed_body) { JSON.parse(response.body) } @@ -102,27 +100,25 @@ let(:full_template_details) { true } context "when not logged in" do - let(:rack_owner) { create(:user) } include_examples "unauthorised JSON response" end - context "when logged in as device owner" do + context "when logged in as member of rack team" do include_context "Logged in as non-admin" include_examples "successful JSON response" do - let(:rack_owner) { authenticated_user } + let!(:team_role) { create(:team_role, user: authenticated_user, team: rack.team) } end end context "when logged in as another user" do include_context "Logged in as non-admin" include_examples "forbidden JSON response" do - let(:rack_owner) { create(:user) } + let!(:team_role) { create(:team_role, user: authenticated_user) } end end - context "when logged in as admin" do + context "when logged in as super admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } include_examples "successful JSON response" let(:parsed_body) { JSON.parse(response.body) } @@ -444,25 +440,32 @@ def send_request context "when not logged in" do include_examples "unauthorised JSON response" - let(:rack_owner) { create(:user) } end - context "when logged in as device owner" do + context "when logged in as device's rack team member" do include_context "Logged in as non-admin" - let(:rack_owner) { authenticated_user } + include_examples "forbidden JSON response" do + let(:request_method) { :patch } + let!(:team_role) { create(:team_role, team: device.rack.team, user: authenticated_user, role: "member") } + end + end + + context "when logged in as device's rack team admin" do + include_context "Logged in as non-admin" + let!(:team_role) { create(:team_role, team: device.rack.team, user: authenticated_user, role: "admin") } include_examples "authorized user updating device" end context "when logged in as another user" do include_context "Logged in as non-admin" include_examples "forbidden JSON response" do - let(:rack_owner) { create(:user) } + let(:request_method) { :patch } + let!(:team_role) { create(:team_role, user: authenticated_user) } end end - context "when logged in as admin" do + context "when logged in as super admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } include_examples "authorized user updating device" end end diff --git a/spec/requests/api/v1/irv/racks_controller_spec.rb b/spec/requests/api/v1/irv/racks_controller_spec.rb index 2c1030b22..f782397f8 100644 --- a/spec/requests/api/v1/irv/racks_controller_spec.rb +++ b/spec/requests/api/v1/irv/racks_controller_spec.rb @@ -14,7 +14,6 @@ context "when logged in as admin" do include_context "Logged in as admin" - let(:user) { authenticated_user } context "when there are no racks" do let(:parsed_body) { JSON.parse(response.body) } @@ -31,7 +30,7 @@ context "when there is one rack" do let!(:template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: user, template: template, cost: 9.99) } + let!(:rack) { create(:rack, template: template, cost: 9.99) } let(:parsed_body) { JSON.parse(response.body) } let(:parsed_racks) { parsed_body["Racks"]["Rack"] } @@ -51,6 +50,7 @@ expect(parsed_rack["name"]).to eq rack.name expect(parsed_rack["uHeight"].to_i).to eq rack.u_height expect(parsed_rack["cost"]).to eq "$9.99" + expect(parsed_rack["teamRole"]).to eq "superAdmin" end it "includes the rack's template" do @@ -66,9 +66,8 @@ it "includes the rack's owner" do get url_under_test, headers: headers, as: :json expected_owner = { - id: (strings? ? user.id.to_s : user.id), - login: user.login, - name: user.name, + id: (strings? ? rack.team.id.to_s : rack.team.id), + name: rack.team.name }.stringify_keys expect(parsed_racks.first["owner"].slice(*expected_owner.keys)).to eq expected_owner end @@ -76,7 +75,7 @@ context "when there are two racks" do let!(:template) { create(:template, :rack_template) } - let!(:racks) { create_list(:rack, 2, user: user, template: template) } + let!(:racks) { create_list(:rack, 2, template: template) } let(:parsed_body) { JSON.parse(response.body) } let(:parsed_racks) { parsed_body["Racks"]["Rack"] } diff --git a/spec/requests/api/v1/metrics_controller_spec.rb b/spec/requests/api/v1/metrics_controller_spec.rb index e84fb6885..8cab71a11 100644 --- a/spec/requests/api/v1/metrics_controller_spec.rb +++ b/spec/requests/api/v1/metrics_controller_spec.rb @@ -2,7 +2,7 @@ RSpec.describe "Api::V1::MetricsController", type: :request do let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: rack_owner, template: rack_template) } + let!(:rack) { create(:rack, template: rack_template) } let(:chassis) { create(:chassis, template: device_template, location: location) } let(:location) { create(:location, rack: rack) } let(:device_template) { create(:template, :device_template) } @@ -21,7 +21,6 @@ context "when logged in as admin" do include_context "Logged in as admin" - let(:user) { authenticated_user } context "when upstream responds unsuccessfully" do before(:each) do diff --git a/spec/requests/api/v1/nodes_controller_spec.rb b/spec/requests/api/v1/nodes_controller_spec.rb index bab54cd63..fb8f4edf0 100644 --- a/spec/requests/api/v1/nodes_controller_spec.rb +++ b/spec/requests/api/v1/nodes_controller_spec.rb @@ -4,9 +4,8 @@ let(:headers) { {} } let(:urls) { Rails.application.routes.url_helpers } let!(:rack_template) { create(:template, :rack_template) } - let!(:rack) { create(:rack, user: rack_owner, template: rack_template) } + let!(:rack) { create(:rack, template: rack_template) } let(:device_template) { create(:template, :device_template) } - let(:rack_owner) { create(:user) } describe "POST :create" do let(:url_under_test) { urls.api_v1_nodes_path } diff --git a/spec/requests/api/v1/racks_controller_spec.rb b/spec/requests/api/v1/racks_controller_spec.rb index 544d4b298..954bd4c1b 100644 --- a/spec/requests/api/v1/racks_controller_spec.rb +++ b/spec/requests/api/v1/racks_controller_spec.rb @@ -20,9 +20,8 @@ it "has the correct owner" do get url_under_test, headers: headers, as: :json expected_owner = { - id: rack.user.id, - login: rack.user.login, - name: rack.user.name, + id: rack.team.id, + name: rack.team.name, }.stringify_keys expect(parsed_rack["owner"].slice(*expected_owner.keys)).to eq expected_owner end @@ -78,7 +77,7 @@ end context "when there is one rack" do - let!(:rack) { create(:rack, user: user, template: template, metadata: rack_metadata) } + let!(:rack) { create(:rack, template: template, metadata: rack_metadata) } let(:rack_metadata) { {} } let(:parsed_body) { JSON.parse(response.body) } @@ -101,7 +100,7 @@ end context "when there are two racks" do - let!(:racks) { create_list(:rack, 2, user: user, template: template, metadata: rack_metadata) } + let!(:racks) { create_list(:rack, 2, template: template, metadata: rack_metadata) } let(:rack_metadata) { {} } let(:parsed_body) { JSON.parse(response.body) } @@ -128,31 +127,29 @@ describe "GET :show" do let(:url_under_test) { urls.api_v1_rack_path(rack) } - let!(:rack) { create(:rack, user: rack_owner, template: template, metadata: rack_metadata) } + let!(:rack) { create(:rack, template: template, metadata: rack_metadata) } let(:rack_metadata) { {} } context "when not logged in" do - let(:rack_owner) { create(:user) } include_examples "unauthorised JSON response" end - context "when logged in as rack owner" do + context "when logged in as rack team member" do include_context "Logged in as non-admin" include_examples "successful JSON response" do - let(:rack_owner) { authenticated_user } + let!(:role) { create(:team_role, team: rack.team, user: authenticated_user) } end end - context "when logged in as another user" do + context "when logged in as non team member" do include_context "Logged in as non-admin" include_examples "forbidden JSON response" do - let(:rack_owner) { create(:user) } + let!(:role) { create(:team_role, user: authenticated_user) } end end context "when logged in as admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } include_examples "successful JSON response" let(:parsed_body) { JSON.parse(response.body) } @@ -176,6 +173,7 @@ describe "POST :create" do let(:url_under_test) { urls.api_v1_racks_path } + let!(:team) { create(:team) } context "when not logged in" do include_examples "unauthorised JSON response" @@ -183,12 +181,11 @@ context "when logged in as admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } let(:valid_attributes) { { rack: { u_height: 20, - user_id: rack_owner.id, + team_id: team.id, status: 'IN_PROGRESS', metadata: { "foo" => "bar" }, creation_output: "all tasks complete", @@ -230,7 +227,7 @@ def send_request send_request parsed_rack = JSON.parse(response.body) expect(parsed_rack["u_height"]).to eq valid_attributes[:rack][:u_height] - expect(parsed_rack["owner"]["id"]).to eq valid_attributes[:rack][:user_id] + expect(parsed_rack["owner"]["id"]).to eq valid_attributes[:rack][:team_id] expect(parsed_rack["metadata"]).to eq valid_attributes[:rack][:metadata] expect(parsed_rack["cost"]).to eq "0.00" expect(parsed_rack["creation_output"]).to eq valid_attributes[:rack][:creation_output] @@ -265,7 +262,6 @@ def send_request let(:url_under_test) { urls.api_v1_rack_path(rack) } let!(:rack) { create(:rack, - user: rack_owner, template: template, metadata: initial_rack_metadata, u_height: initial_u_height, @@ -365,27 +361,35 @@ def send_request context "when not logged in" do include_examples "unauthorised JSON response" - let(:rack_owner) { create(:user) } end - context "when logged in as rack owner" do + context "when logged in as admin of rack's team" do include_context "Logged in as non-admin" - let(:rack_owner) { authenticated_user } + let!(:team_role) { create(:team_role, user: authenticated_user, team: rack.team, role: "admin") } include_examples "authorized user updating rack" do let(:can_update_order_id) { false } end end + context "when logged in as member of rack's team" do + include_context "Logged in as non-admin" + let!(:team_role) { create(:team_role, user: authenticated_user, team: rack.team, role: "member") } + include_examples "forbidden JSON response" do + let(:request_method) { :patch } + let!(:team_role) { create(:team_role, user: authenticated_user) } + end + end + context "when logged in as another user" do include_context "Logged in as non-admin" include_examples "forbidden JSON response" do - let(:rack_owner) { create(:user) } + let(:request_method) { :patch } + let!(:team_role) { create(:team_role, user: authenticated_user) } end end context "when logged in as admin" do include_context "Logged in as admin" - let(:rack_owner) { create(:user) } include_examples "authorized user updating rack" do let(:can_update_order_id) { true } end diff --git a/spec/requests/api/v1/teams_controller_spec.rb b/spec/requests/api/v1/teams_controller_spec.rb new file mode 100644 index 000000000..3b2d2683d --- /dev/null +++ b/spec/requests/api/v1/teams_controller_spec.rb @@ -0,0 +1,418 @@ +require 'rails_helper' + +RSpec.describe "Api::V1::TeamsControllers", type: :request do + let(:headers) { {} } + let(:urls) { Rails.application.routes.url_helpers } + let(:param_key) { "team" } + + describe "GET :index" do + let(:url_under_test) { urls.api_v1_teams_path } + + let(:parsed_body) { JSON.parse(response.body) } + let(:parsed_teams) { parsed_body } + + context "when not logged in" do + include_examples "unauthorised JSON response" + end + + context "when logged in as admin" do + include_context "Logged in as admin" + + context "when there are no teams" do + include_examples "successful JSON response" + + it "includes no teams" do + get url_under_test, headers: headers, as: :json + expect(parsed_teams.length).to eq 0 + end + end + + context "when there is one team" do + let!(:team) { create(:team) } + + include_examples "successful JSON response" + + it "includes one team" do + get url_under_test, headers: headers, as: :json + expect(parsed_teams.length).to eq 1 + result = parsed_teams.first + expect(result['id']).to eq team.id + expect(result['name']).to eq team.name + expect(result['cost']).to eq team.cost.to_s + end + end + + context "when there are multiple teams" do + let!(:team) { create(:team, name: "Aardvarks") } + let!(:another_team) { create(:team, name: "Zebras") } + + include_examples "successful JSON response" + + it "includes two teams" do + get url_under_test, headers: headers, as: :json + expect(parsed_teams.length).to eq 2 + result = parsed_teams.last + expect(result['id']).to eq another_team.id + expect(result['name']).to eq another_team.name + expect(result['cost']).to eq another_team.cost.to_s + end + + it "includes the expected teams" do + expected_ids = [team.id, another_team.id].sort + + get url_under_test, headers: headers, as: :json + + retrieved_ids = parsed_teams.map { |r| r["id"] }.sort + expect(retrieved_ids).to eq expected_ids + end + end + end + + context "when logged in as non-admin" do + include_context "Logged in as non-admin" + include_examples "successful JSON response" + + let!(:team) { create(:team) } + let!(:another_team) { create(:team) } + let!(:team_role) { create(:team_role, user: authenticated_user, team: team) } + + it "includes team team is a part of" do + get url_under_test, headers: headers, as: :json + expect(parsed_teams.length).to eq 1 + expect(parsed_teams.first['id']).to eq team.id + end + + it "does not include other teams" do + get url_under_test, headers: headers, as: :json + + expect(parsed_teams.map { |u| u['id'] }).not_to include another_team.id + end + end + end + + describe "PATCH :update" do + let(:url_under_test) { urls.api_v1_team_path(team) } + let(:initial_value) { nil } + + %w( project_id billing_acct_id ).each do |attr_under_test| + + let(:team) { create(:team, attr_under_test => initial_value) } + + shared_examples "can update team's #{attr_under_test}" do + context "when first setting a team's #{attr_under_test}" do + include_examples "update generic JSON API endpoint examples" do + before(:each) do + expect(team.send(attr_under_test)).to be_blank + end + + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => SecureRandom.uuid } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + context "when unsetting a team's #{attr_under_test}" do + include_examples "update generic JSON API endpoint examples" do + before(:each) { team.send("#{attr_under_test}=", SecureRandom.uuid); team.save! } + + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => nil } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + context "when updating team's #{attr_under_test}" do + include_examples "update generic JSON API endpoint examples" do + before(:each) { team.send("#{attr_under_test}=", SecureRandom.uuid); team.save! } + + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => SecureRandom.uuid } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + end + + shared_examples "cannot update team's #{attr_under_test}" do + context "when first setting a team's #{attr_under_test}" do + include_examples "cannot update generic JSON API endpoint examples" do + before(:each) do + expect(team.send(attr_under_test)).to be_blank + end + + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => SecureRandom.uuid } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + context "when unsetting a team's #{attr_under_test}" do + include_examples "cannot update generic JSON API endpoint examples" do + before(:each) { team.send("#{attr_under_test}=", SecureRandom.uuid); team.save! } + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => nil } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + context "when updating team's #{attr_under_test}" do + include_examples "cannot update generic JSON API endpoint examples" do + before(:each) { team.send("#{attr_under_test}=", SecureRandom.uuid); team.save! } + + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => SecureRandom.uuid } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + end + end + + shared_examples "can update team's cost" do + include_examples "update generic JSON API endpoint examples" do + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { cost: 100.001 } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + shared_examples "cannot update team's cost" do + include_examples "cannot update generic JSON API endpoint examples" do + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { cost: 100.001 } + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + %w( billing_period_start billing_period_end ).each do |attr_under_test| + shared_examples "can update team's #{attr_under_test}" do + before(:each) do + team.billing_period_start = Date.current - 1.month + team.billing_period_end = Date.current + team.save! + end + + include_examples "update generic JSON API endpoint examples" do + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => team.send(attr_under_test) + 1.day} + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + + shared_examples "cannot update team's #{attr_under_test}" do + before(:each) do + team.billing_period_start = Date.current - 1.month + team.billing_period_end = Date.current + team.save! + end + include_examples "cannot update generic JSON API endpoint examples" do + let(:object_under_test) { team } + let(:param_key) { "team" } + let(:valid_attributes) { + { + team: { attr_under_test => team.send(attr_under_test) + 1.day} + } + } + let(:invalid_attributes) { + { + team: { } + } + } + end + end + end + + context "when not logged in" do + include_examples "unauthorised JSON response" do + let(:request_method) { :patch } + end + end + + context "when logged in as team member" do + include_context "Logged in as non-admin" + let(:team) { create(:team) } + let!(:team_role) { create(:team_role, team: team, user: authenticated_user, role: "member") } + include_examples "forbidden JSON response" do + let(:request_method) { :patch } + end + end + + context "when logged in as team admin user" do + include_context "Logged in as non-admin" + let(:team) { create(:team) } + let!(:team_role) { create(:team_role, team: team, user: authenticated_user, role: "admin") } + include_examples "forbidden JSON response" do + let(:request_method) { :patch } + end + end + + context "when logged in as some user not part of team" do + include_context "Logged in as non-admin" + let(:team) { create(:team) } + include_examples "forbidden JSON response" do + let(:request_method) { :patch } + end + end + + context "when logged in as super admin user" do + include_context "Logged in as admin" + it_behaves_like "can update team's project_id" + it_behaves_like "can update team's cost" + it_behaves_like "can update team's billing_acct_id" + it_behaves_like "can update team's billing_period_start" + it_behaves_like "can update team's billing_period_end" + end + end + + describe "DELETE :destroy" do + before(:each) do + # Ensure the teams are created before the test runs. Else the `change` + # expectation may not work correctly. + authenticated_user + team_to_delete + create(:cloud_service_config) + end + + let(:url_under_test) { urls.api_v1_team_path(team_to_delete) } + let(:team_to_delete) { create(:team) } + + def send_request + delete url_under_test, + headers: headers, + as: :json + end + + context "when not logged in" do + include_context "Not logged in" + include_examples "unauthorised JSON response" do + let(:request_method) { :delete } + end + end + + context "when logged in as a non super-admin user" do + include_context "Logged in as non-admin" + include_examples "forbidden JSON response" do + let(:request_method) { :delete } + end + end + + context "when logged in as super admin" do + include_context "Logged in as admin" + + context "when team has no racks" do + let(:team_to_delete) { create(:team) } + + it "deletes the team" do + expect(team_to_delete.deleted_at).to be_nil + send_request + team_to_delete.reload + expect(team_to_delete.deleted_at).not_to be_nil + expect(DeleteTeamJob).to have_been_enqueued + end + end + + context "when team to delete is has racks" do + let(:team_to_delete) { create(:team, :with_empty_rack) } + + it "does not delete the team" do + expect { + send_request + }.not_to change(Team, :count) + end + + it "responds with a 422 unprocessable_entity" do + send_request + expect(response).to have_http_status :unprocessable_entity + end + + it "contains the expected error message" do + send_request + error_document = JSON.parse(response.body) + expect(error_document).to have_key "errors" + expect(error_document["errors"].length).to eq 1 + expect(error_document["errors"][0]["title"]).to eq "Unprocessable Content" + expect(error_document["errors"][0]["description"]).to match /Cannot delete team as they have\b.*\bracks/i + end + end + end + end +end diff --git a/spec/requests/api/v1/users_controller_spec.rb b/spec/requests/api/v1/users_controller_spec.rb index 067555c71..f3514329c 100644 --- a/spec/requests/api/v1/users_controller_spec.rb +++ b/spec/requests/api/v1/users_controller_spec.rb @@ -29,9 +29,6 @@ expect(result['root']).to eq true expect(result['name']).to eq authenticated_user.name expect(result['email']).to eq authenticated_user.email - expect(result.keys.include?('cost')).to eq false - expect(result.keys.include?('billing_period_start')).to eq false - expect(result.keys.include?('billing_period_end')).to eq false end end @@ -46,14 +43,11 @@ result = parsed_users.first expect(result['id']).to eq other_user.id expect(result['root']).to eq false - expect(result['cost']).to eq '0.00' expect(result['name']).to eq other_user.name expect(result['email']).to eq other_user.email - expect(result.keys.include?('billing_period_start')).to eq true - expect(result.keys.include?('billing_period_end')).to eq true end - it "includes the expected racks" do + it "includes the expected users" do expected_ids = [authenticated_user.id, other_user.id].sort get url_under_test, headers: headers, as: :json @@ -121,7 +115,7 @@ let(:url_under_test) { urls.api_v1_user_path(user) } let(:initial_value) { nil } - %w( project_id cloud_user_id billing_acct_id ).each do |attr_under_test| + %w( cloud_user_id ).each do |attr_under_test| let(:user) { create(:user, attr_under_test => initial_value) } @@ -246,88 +240,7 @@ end end end - - shared_examples "can update user's cost" do - include_examples "update generic JSON API endpoint examples" do - let(:object_under_test) { user } - let(:param_key) { "user" } - let(:valid_attributes) { - { - user: { cost: 100.001 } - } - } - let(:invalid_attributes) { - { - user: { } - } - } - end - end - - shared_examples "cannot update user's cost" do - include_examples "cannot update generic JSON API endpoint examples" do - let(:object_under_test) { user } - let(:param_key) { "user" } - let(:valid_attributes) { - { - user: { cost: 100.001 } - } - } - let(:invalid_attributes) { - { - user: { } - } - } - end - end - - %w( billing_period_start billing_period_end ).each do |attr_under_test| - shared_examples "can update user's #{attr_under_test}" do - before(:each) do - user.billing_period_start = Date.current - 1.month - user.billing_period_end = Date.current - user.save! - end - - include_examples "update generic JSON API endpoint examples" do - let(:object_under_test) { user } - let(:param_key) { "user" } - let(:valid_attributes) { - { - user: { attr_under_test => user.send(attr_under_test) + 1.day} - } - } - let(:invalid_attributes) { - { - user: { } - } - } - end - end - - shared_examples "cannot update user's #{attr_under_test}" do - before(:each) do - user.billing_period_start = Date.current - 1.month - user.billing_period_end = Date.current - user.save! - end - include_examples "cannot update generic JSON API endpoint examples" do - let(:object_under_test) { user } - let(:param_key) { "user" } - let(:valid_attributes) { - { - user: { attr_under_test => user.send(attr_under_test) + 1.day} - } - } - let(:invalid_attributes) { - { - user: { } - } - } - end - end - end - + context "when not logged in" do include_examples "unauthorised JSON response" do let(:request_method) { :patch } @@ -337,12 +250,7 @@ context "when logged in as updated user" do include_context "Logged in as non-admin" let(:user) { authenticated_user } - it_behaves_like "cannot update user's project_id" it_behaves_like "cannot update user's cloud_user_id" - it_behaves_like "cannot update user's cost" - it_behaves_like "cannot update user's billing_acct_id" - it_behaves_like "cannot update user's billing_period_start" - it_behaves_like "cannot update user's billing_period_end" end context "when logged in as some other non-admin user" do @@ -354,12 +262,7 @@ context "when logged in as admin user" do include_context "Logged in as admin" - it_behaves_like "can update user's project_id" it_behaves_like "can update user's cloud_user_id" - it_behaves_like "can update user's cost" - it_behaves_like "can update user's billing_acct_id" - it_behaves_like "can update user's billing_period_start" - it_behaves_like "can update user's billing_period_end" end end @@ -418,7 +321,7 @@ def send_request end context "when user to delete is a non-admin with racks" do - let(:user_to_delete) { create(:user, :with_empty_rack) } + let(:user_to_delete) { create(:user, :member_of_empty_rack) } it "does not delete the user" do expect { diff --git a/spec/requests/invoices_controller_spec.rb b/spec/requests/invoices_controller_spec.rb index bae88ae9f..6bbb95c10 100644 --- a/spec/requests/invoices_controller_spec.rb +++ b/spec/requests/invoices_controller_spec.rb @@ -2,31 +2,20 @@ RSpec.describe "InvoicesControllers", type: :request do let(:headers) { {} } + let(:team) { create(:team) } let(:urls) { Rails.application.routes.url_helpers } describe "GET :index" do - let(:url_under_test) { urls.invoices_path } + let(:url_under_test) { urls.team_invoices_path(team) } - shared_examples "does not fetch invoice" do - it "does not fetch an invoice" do - get url_under_test, headers: headers - expect(GetInvoicesJob).not_to have_been_enqueued - expect(GetInvoicesJob).not_to have_been_performed - end - end - - context "when logged in as admin" do - include_context "Logged in as admin" - include_examples "does not fetch invoice" - - it "redirects to the home page" do - get url_under_test, headers: headers - expect(response).to redirect_to urls.root_path + shared_examples 'fetching invoices' do + shared_examples "does not fetch invoice" do + it "does not fetch an invoice" do + get url_under_test, headers: headers + expect(GetInvoicesJob).not_to have_been_enqueued + expect(GetInvoicesJob).not_to have_been_performed + end end - end - - context "when logged in as non-admin" do - include_context "Logged in as non-admin" context "when cloud service has not been configured" do before(:each) { CloudServiceConfig.delete_all } @@ -38,16 +27,26 @@ end end - context "when user does not have a billing account" do + context "when team does not have a billing account" do before(:each) { create(:cloud_service_config) } - before(:each) { expect(authenticated_user.billing_acct_id).to be_nil } + before(:each) { expect(team.billing_acct_id).to be_nil } include_examples "does not fetch invoice" it "displays an error flash" do get url_under_test, headers: headers - expect(response.body).to include "You do not yet have a billing account id" + expect(response.body).to include "The team does not yet have a billing account id." end end end + + context "when logged in as admin" do + include_context "Logged in as admin" + include_examples "fetching invoices" + end + + context "when logged in as non-admin" do + include_context "Logged in as non-admin" + include_examples "fetching invoices" + end end end diff --git a/spec/support/shared_examples/jobs.rb b/spec/support/shared_examples/jobs.rb index 8dafc6fba..85de6582d 100644 --- a/spec/support/shared_examples/jobs.rb +++ b/spec/support/shared_examples/jobs.rb @@ -12,3 +12,111 @@ expect(decoded["exp"]).to eq decoded["iat"] + 60 end end + +RSpec.shared_examples "creating team job" do + describe "url" do + let(:team_service_path) { "/team" } + + subject { super().send(:url) } + + it "uses the correct ip, port and path" do + expect(subject).to eq "#{cloud_service_config.user_handler_base_url}#{team_service_path}" + end + end + + describe "body" do + subject { super().send(:body).with_indifferent_access } + + it "contains the team's name" do + expect(subject["name"]).to eq team.name + end + + context "when the team has a project id" do + let(:team) { create(:team, project_id: Faker::Internet.uuid) } + + it "contains the team's project id" do + expect(team.project_id).not_to be_nil + expect(subject["project_id"]).to eq team.project_id + end + end + + context "when the team does not have a project id" do + it "does not contain the team's project id" do + expect(team.project_id).to be_nil + expect(subject).not_to have_key "project_id" + expect(subject).not_to have_key :project_id + end + end + + context "when the team has a billing account id" do + let(:team) { create(:team, billing_acct_id: Faker::Internet.uuid) } + + it "contains the team's billing account id" do + expect(team.billing_acct_id).not_to be_nil + expect(subject["billing_acct_id"]).to eq team.billing_acct_id + end + end + + context "when the team does not have a billing account id" do + it "does not contain the team's billing account id" do + expect(team.billing_acct_id).to be_nil + expect(subject).not_to have_key "billing_account_id" + expect(subject).not_to have_key :billing_account_id + end + end + + it "contains the correct cloud environment config" do + expect(subject[:cloud_env]).to eq({ + "auth_url" => cloud_service_config.internal_auth_url, + "user_id" => cloud_service_config.admin_user_id, + "password" => cloud_service_config.admin_foreign_password, + "project_id" => cloud_service_config.admin_project_id + }) + end + end + + describe "updating the team's details from the response" do + let(:team_service_path) { "/team" } + context "when response does not contain expected fields" do + let(:response_body) { {} } + + before(:each) do + stubs.post(team_service_path) { |env| [ 201, {}, response_body ] } + end + + it "raises ActiveModel::ValidationError" do + expect { subject.call }.to raise_error ActiveModel::ValidationError + end + + it "does not update the project_id" do + expect { subject.call rescue nil }.not_to change(team, :project_id).from(nil) + end + + it "does not update the billing_acct_id" do + expect { subject.call rescue nil }.not_to change(team, :billing_acct_id).from(nil) + end + end + + context "when response contains expected fields" do + let(:project_id) { SecureRandom.uuid } + let(:billing_acct_id) { SecureRandom.uuid } + let(:response_body) { + {project_id: project_id, billing_acct_id: billing_acct_id} + .stringify_keys + } + + before(:each) do + stubs.post(team_service_path) { |env| [ 201, {}, response_body ] } + end + + it "updates the team's project_id and billing_acct_id" do + expect { subject.call } + .to change(team, :project_id).from(nil).to(project_id) + .and change(team, :billing_acct_id).from(nil).to(billing_acct_id) + end + end + end + + include_examples 'auth token header' +end + diff --git a/spec/system/invoices/resource_table_spec.rb b/spec/system/invoices/resource_table_spec.rb index 1256bfd06..c2132e84d 100644 --- a/spec/system/invoices/resource_table_spec.rb +++ b/spec/system/invoices/resource_table_spec.rb @@ -2,7 +2,8 @@ RSpec.describe "invoices index page table", type: :system do let(:user_password) { 'user-password' } - let!(:user) { create(:user, :with_openstack_details, password: user_password) } + let!(:user) { create(:user, :with_openstack_account, :as_team_admin, password: user_password, team: team) } + let!(:team) { create(:team, :with_openstack_details) } let(:items_per_page) { 20 } before(:each) do @@ -24,14 +25,14 @@ "total_invoices" => total_invoices, "invoices" => invoices.map do |invoice| attrs = invoice.attributes.except("account") - attrs["account_id"] = user.billing_acct_id + attrs["account_id"] = team.billing_acct_id attrs end, } } # The API does the pagination for us, we emulate that here. - let(:invoices) { build_list(:invoice, [total_invoices, items_per_page].min, account: user) } + let(:invoices) { build_list(:invoice, [total_invoices, items_per_page].min, account: team) } describe "pagination" do context "when there are 20 or fewer invoices" do @@ -39,8 +40,8 @@ let(:total_invoices) { 10 } it "lists all invoices" do - visit invoices_path - expect(current_path).to eq(invoices_path) + visit team_invoices_path(team) + expect(current_path).to eq(team_invoices_path(team)) table = find('.resource_table') invoices.each do |invoice| @@ -54,8 +55,8 @@ let(:total_invoices) { 30 } it "lists the first 20 invoices" do - visit invoices_path - expect(current_path).to eq(invoices_path) + visit team_invoices_path(team) + expect(current_path).to eq(team_invoices_path(team)) table = find('.resource_table') invoices.each do |invoice| @@ -65,8 +66,8 @@ end it "displays enabled pagination controls" do - visit invoices_path - expect(current_path).to eq(invoices_path) + visit team_invoices_path(team) + expect(current_path).to eq(team_invoices_path(team)) controls = find('.pagination_controls') expect(controls).to have_content "Displaying items 1-20 of 30" @@ -83,8 +84,8 @@ describe "invoice table rows" do let(:total_invoices) { 10 } it "displays correct amount for invoice" do - visit invoices_path - expect(current_path).to eq(invoices_path) + visit team_invoices_path(team) + expect(current_path).to eq(team_invoices_path(team)) table = find('.resource_table') invoices.each do |invoice| @@ -95,13 +96,13 @@ end it "displays a link to view the invoice" do - visit invoices_path - expect(current_path).to eq(invoices_path) + visit team_invoices_path(team) + expect(current_path).to eq(team_invoices_path(team)) table = find('.resource_table') invoices.each do |invoice| tr = table.find("tr[data-test='invoice-#{invoice.invoice_id}']") - expect(tr).to have_link("View invoice", href: invoice_path(invoice)) + expect(tr).to have_link("View invoice", href: team_invoice_path(team, invoice)) end end end diff --git a/spec/system/team_roles/resource_table_spec.rb b/spec/system/team_roles/resource_table_spec.rb new file mode 100644 index 000000000..307e9fcf3 --- /dev/null +++ b/spec/system/team_roles/resource_table_spec.rb @@ -0,0 +1,278 @@ +require 'rails_helper' + +RSpec.describe "team roles index page table", type: :system do + let(:admin_password) { 'admin-password' } + let!(:admin) { create(:user, :admin, password: admin_password) } + let!(:team) { create(:team) } + let(:items_per_page) { 20 } + + before(:each) do + visit new_user_session_path + expect(current_path).to eq(new_user_session_path) + fill_in "Username", with: admin.login + fill_in "Password", with: admin_password + click_on "Login" + end + + describe "pagination" do + context "when there are 20 or fewer roles" do + let!(:roles) { create_list(:team_role, 10, team: team) } + let!(:other_team_roles) { create_list(:team_role, 11) } + + it "lists all roles" do + visit team_team_roles_path(team) + expect(current_path).to eq(team_team_roles_path(team)) + + table = find('.resource_table') + roles.each do |role| + expect(table).to have_content(role.user.id) + expect(table).to have_content(role.user.name) + expect(table).to have_content(role.role) + end + end + + it "does not display pagination controls" do + visit team_team_roles_path(team) + expect(current_path).to eq(team_team_roles_path(team)) + + controls = find('.pagination_controls') + expect(controls).not_to have_content "Displaying" + expect(controls).not_to have_css('.page.prev') + expect(controls).not_to have_css('.page.next') + end + end + + context "when there are more than 20 roles" do + let!(:more_roles) { create_list(:team_role, 30, team: team) } + + it "lists the first 20 roles" do + visit team_team_roles_path(team) + expect(current_path).to eq(team_team_roles_path(team)) + + table = find('.resource_table') + roles = team.team_roles.order(:id).offset(0).limit(items_per_page) + roles.each do |role| + expect(table).to have_content(role.user.id) + expect(table).to have_content(role.user.name) + expect(table).to have_content(role.role) + end + end + + it "displays enabled pagination controls" do + visit team_team_roles_path(team) + expect(current_path).to eq(team_team_roles_path(team)) + + controls = find('.pagination_controls') + expect(controls).to have_content "Displaying items 1-20 of 30" + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + end + + it "allows navigating to the next page" do + visit team_team_roles_path(team) + expect(current_path).to eq(team_team_roles_path(team)) + table = find('.resource_table') + controls = find('.pagination_controls') + + # Teams expected to be on second page are not displayed. + second_page_roles = team.team_roles.order(:id).offset(items_per_page).limit(items_per_page) + second_page_roles.each do |role| + expect(table).not_to have_content(role.user.login) + end + + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + + click_link "Next" + table = find('.resource_table') + controls = find('.pagination_controls') + + # Users expected to be on second page are displayed. + second_page_roles.each do |role| + expect(table).to have_content(role.user.id) + expect(table).to have_content(role.user.name) + expect(table).to have_content(role.role) + end + + # Expect prev navigation to not be disabled. + expect(controls).not_to have_css('.page.prev.disabled') + expect(controls).to have_css('.page.prev') + expect(controls).to have_css('a[rel="prev"]') + # Expect next navigation to be disabled. + expect(controls).to have_css('.page.next.disabled') + end + + it "allows navigating to the prev page" do + visit team_team_roles_path(team, page: 2) + expect(current_path).to eq(team_team_roles_path(team)) + table = find('.resource_table') + controls = find('.pagination_controls') + + # Roles expected to be on first page are not displayed. + first_page_roles = team.team_roles.order(:id).offset(0).limit(items_per_page) + first_page_roles.each do |role| + expect(table).not_to have_content(role.user.name) + end + + # Expect prev navigation to not be disabled. + expect(controls).not_to have_css('.page.prev.disabled') + expect(controls).to have_css('.page.prev') + expect(controls).to have_css('a[rel="prev"]') + # Expect next navigation to be disabled. + expect(controls).to have_css('.page.next.disabled') + + click_link "Prev" + table = find('.resource_table') + controls = find('.pagination_controls') + + # Users expected to be on first page are displayed. + first_page_roles.each do |role| + expect(table).to have_content(role.user.id) + expect(table).to have_content(role.user.name) + expect(table).to have_content(role.role) + end + + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + end + end + end + + describe "sorting" do + before(:each) do + create_list(:team_role, items_per_page, team: team) + create(:team_role, team: team, user: create(:user, name: 'ZZZ-aaa'), role: "admin") + create_list(:team_role, items_per_page, team: team) + create(:team_role, team: team, user: create(:user, name: 'ZZZ-ZZZ')) + end + + it "sorts by id by default" do + visit team_team_roles_path(team) + expected_roles = team.team_roles.order(id: :asc).offset(0).limit(items_per_page) + table = find('.resource_table') + + expected_roles.each_with_index do |role, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: role.user.name) + end + end + + it "allows sorting in reverse order" do + visit team_team_roles_path(team, direction: :asc, sort: :user_id) + table = find('.resource_table') + header = table.find('thead') + header.click_link "User" + + expected_roles = team.team_roles.order(user_id: :desc).offset(0).limit(items_per_page) + expected_roles.each_with_index do |role, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: role.user.id) + end + end + + it "allows sorting by another column" do + expected_roles = team.team_roles.order(role: :asc).offset(0).limit(items_per_page) + # Let's make sure we're not just retesting the default behaviour. + expect(expected_roles.map(&:role)).not_to eq team.team_roles.order(:id).map(&:role) + + visit team_team_roles_path(team) + table = find('.resource_table') + header = table.find('thead') + header.click_link "Role" + + expected_roles.each_with_index do |role, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: role.user.id) + end + end + + it "respects both sort order and pagination" do + visit team_team_roles_path(team, direction: :asc, sort: :role) + table = find('.resource_table') + + expect(table).to have_content('ZZZ-aaa') + expect(table).not_to have_content('ZZZ-ZZZ') + + header = table.find('thead') + header.click_link "Role" + + table = find('.resource_table') + expect(table).not_to have_content('ZZZ-aaa') + expect(table).not_to have_content('ZZZ-ZZZ') + + click_link "Next" + + table = find('.resource_table') + expect(table).not_to have_content('ZZZ-aaa') + expect(table).not_to have_content('ZZZ-ZZZ') + + click_link "Next" + table = find('.resource_table') + expect(table).to have_content('ZZZ-aaa') + expect(table).not_to have_content('ZZZ-ZZZ') + end + end + + describe "searching" do + before(:each) do + # Create enough roles to fill an entire page. They will have users with names such as + # such as `user 1`, `user 2`, etc.. + create_list(:team_role, items_per_page, team: team, role: "admin") + # Create a couple of roles with users with role names that sort after the earlier + # users. They will not be displayed on the initial page load. + create(:team_role, team: team, user: create(:user, name: 'zzz-wanted-1'), role: "member") + create(:team_role, team: team, user: create(:user, name: 'zzz-wanted-2'), role: "member") + end + + it "allows filtering for roles" do + visit team_team_roles_path(team, direction: :asc, sort: :role) + table = find('.resource_table') + + # The table displays 20 roles and none are the wanted ones. + expect(table.all('tbody tr').count).to eq 20 + expect(table).not_to have_content('zzz-wanted-1') + expect(table).not_to have_content('zzz-wanted-2') + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'member' + click_on "Go" + end + + # The table displays the wanted roles and no others. + expect(table).to have_content('zzz-wanted-1') + expect(table).to have_content('zzz-wanted-2') + expect(table.all('tbody tr').count).to eq 2 + end + + it "allows searching by user's name" do + visit team_team_roles_path(team, direction: :asc, sort: :role) + table = find('.resource_table') + + # The table displays 20 users and none are the wanted ones. + expect(table.all('tbody tr').count).to eq 20 + expect(table).not_to have_content('zzz-wanted-1') + expect(table).not_to have_content('zzz-wanted-2') + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'wanted' + click_on "Go" + end + + # The table displays the wanted users and no others. + expect(table).to have_content('zzz-wanted-1') + expect(table).to have_content('zzz-wanted-2') + expect(table.all('tbody tr').count).to eq 2 + end + end +end diff --git a/spec/system/teams/resource_table_spec.rb b/spec/system/teams/resource_table_spec.rb new file mode 100644 index 000000000..b312801a3 --- /dev/null +++ b/spec/system/teams/resource_table_spec.rb @@ -0,0 +1,342 @@ +require 'rails_helper' + +RSpec.describe "teams index page table", type: :system do + let(:admin_password) { 'admin-password' } + let(:regular_user_password) { 'password' } + let!(:admin) { create(:user, :admin, password: admin_password) } + let(:regular_user) { create(:user, password: regular_user_password) } + let(:items_per_page) { 20 } + + context "super admin" do + before(:each) do + visit new_user_session_path + expect(current_path).to eq(new_user_session_path) + fill_in "Username", with: admin.login + fill_in "Password", with: admin_password + click_on "Login" + end + + describe "pagination" do + context "when there are 20 or fewer teams" do + let!(:teams) { create_list(:team, 10) } + + it "lists all teams" do + visit teams_path + expect(current_path).to eq(teams_path) + + table = find('.resource_table') + teams.each do |team| + expect(table).to have_content(team.id) + expect(table).to have_content(team.name) + end + end + + it "does not display pagination controls" do + visit teams_path + expect(current_path).to eq(teams_path) + + controls = find('.pagination_controls') + expect(controls).not_to have_content "Displaying" + expect(controls).not_to have_css('.page.prev') + expect(controls).not_to have_css('.page.next') + end + end + + context "when there are more than 20 teams" do + let!(:teams) { create_list(:team, 30) } + + it "lists the first 20 teams" do + visit teams_path + expect(current_path).to eq(teams_path) + + table = find('.resource_table') + teams = Team.all.order(:id).offset(0).limit(items_per_page) + teams.each do |team| + expect(table).to have_content(team.id) + expect(table).to have_content(team.name) + end + end + + it "displays enabled pagination controls" do + visit teams_path + expect(current_path).to eq(teams_path) + + controls = find('.pagination_controls') + expect(controls).to have_content "Displaying items 1-20 of 30" + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + end + + it "allows navigating to the next page" do + visit teams_path + expect(current_path).to eq(teams_path) + table = find('.resource_table') + controls = find('.pagination_controls') + + # Teams expected to be on second page are not displayed. + second_page_teams = Team.all.order(:id).offset(items_per_page).limit(items_per_page) + second_page_teams.each do |team| + expect(table).not_to have_content(team.name) + end + + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + + click_link "Next" + table = find('.resource_table') + controls = find('.pagination_controls') + + # Teams expected to be on second page are displayed. + second_page_teams.each do |team| + expect(table).to have_content(team.id) + expect(table).to have_content(team.name) + end + + # Expect prev navigation to not be disabled. + expect(controls).not_to have_css('.page.prev.disabled') + expect(controls).to have_css('.page.prev') + expect(controls).to have_css('a[rel="prev"]') + # Expect next navigation to be disabled. + expect(controls).to have_css('.page.next.disabled') + end + + it "allows navigating to the prev page" do + visit teams_path(page: 2) + expect(current_path).to eq(teams_path) + table = find('.resource_table') + controls = find('.pagination_controls') + + # Teams expected to be on first page are not displayed. + first_page_teams = Team.all.order(:id).offset(0).limit(items_per_page) + first_page_teams.each do |team| + expect(table).not_to have_content(team.name) + end + + # Expect prev navigation to not be disabled. + expect(controls).not_to have_css('.page.prev.disabled') + expect(controls).to have_css('.page.prev') + expect(controls).to have_css('a[rel="prev"]') + # Expect next navigation to be disabled. + expect(controls).to have_css('.page.next.disabled') + + click_link "Prev" + table = find('.resource_table') + controls = find('.pagination_controls') + + # Teams expected to be on first page are displayed. + first_page_teams.each do |team| + expect(table).to have_content(team.id) + expect(table).to have_content(team.name) + end + + # Expect prev navigation to be disabled. + expect(controls).to have_css('.page.prev.disabled') + # Expect next navigation to not be disabled. + expect(controls).not_to have_css('.page.next.disabled') + expect(controls).to have_css('.page.next') + expect(controls).to have_css('a[rel="next"]') + end + end + end + + describe "sorting" do + before(:each) do + create_list(:team, items_per_page, :with_openstack_details) + create(:team, project_id: '1' * 10, name: 'Team A') # Create a team with an "earlier" project id, but "later" name. + create(:team, project_id: 'Z' * 10, name: 'Team B') # Create a team with a "later" project id, and name. + create_list(:team, items_per_page, :with_openstack_details) + end + + it "sorts by id by default" do + visit teams_path + expected_teams = Team.all.order(id: :asc).offset(0).limit(items_per_page) + table = find('.resource_table') + + expected_teams.each_with_index do |team, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: team.name) + end + end + + it "allows sorting in reverse order" do + visit teams_path + table = find('.resource_table') + header = table.find('thead') + header.click_link "Id" + + expected_teams = Team.all.order(id: :desc).offset(0).limit(items_per_page) + expected_teams.each_with_index do |team, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: team.name) + end + end + + it "allows sorting by another column" do + expected_teams = Team.all.order(name: :asc).offset(0).limit(items_per_page) + # Let's make sure we're not just retesting the default behaviour. + expect(expected_teams.map(&:id)).not_to eq Team.all.order(:id).map(&:id) + + visit teams_path + table = find('.resource_table') + header = table.find('thead') + header.click_link "Name" + + expected_teams.each_with_index do |team, idx| + expect(table).to have_css("tr:nth-child(#{idx + 1})", text: team.name) + end + end + + it "respects both sort order and pagination" do + visit teams_path(direction: :asc, sort: :name) + table = find('.resource_table') + + expect(table).not_to have_content('Team A') + expect(table).not_to have_content('Team B') + + header = table.find('thead') + header.click_link "Project ID" + + table = find('.resource_table') + expect(table).to have_content('Team A') + expect(table).not_to have_content('Team B') + + click_link "Next" + + table = find('.resource_table') + expect(table).not_to have_content('Team A') + expect(table).not_to have_content('Team B') + + click_link "Next" + table = find('.resource_table') + expect(table).not_to have_content('Team A') + expect(table).to have_content('Team B') + end + end + + describe "searching" do + before(:each) do + # Create enough teams to fill an entire page. They will have names + # such as `Team 1`, `Team 2`, etc.. + create_list(:team, items_per_page) + # Create a couple of teams with names that sort after the earlier + # teams. They will not be displayed on the initial page load. + create(:team, name: 'zzz wanted 1') + create(:team, name: 'zzz wanted 2') + end + + it "allows filtering for teams" do + visit teams_path(direction: :asc, sort: :name) + table = find('.resource_table') + + # The table displays 20 teams and none are the wanted ones. + expect(table.all('tbody tr').count).to eq 20 + expect(table).not_to have_content('zzz wanted 1') + expect(table).not_to have_content('zzz wanted 2') + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'wanted' + click_on "Go" + end + + # The table displays the wanted teams and no others. + expect(table).to have_content('zzz wanted 1') + expect(table).to have_content('zzz wanted 2') + expect(table.all('tbody tr').count).to eq 2 + end + + context 'teams have users' do + let!(:user) { create(:user, name: "Dobby") } + let!(:team_role) { create(:team_role, user: user) } + let!(:another_role) { create(:team_role, user: user) } + + it "allows searching for teams by user in team" do + visit teams_path + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'Dobby' + click_on "Go" + end + + table = find('.resource_table') + expect(table.all('tbody tr').count).to eq 2 + expect(table).to have_content(team_role.team.name) + expect(table).to have_content(another_role.team.name) + + within(controls) do + fill_in "Search", with: 'Snape' + click_on "Go" + end + + expect(page).to have_content('No teams have been found') + end + end + end + end + + context 'regular user' do + before(:each) do + visit new_user_session_path + expect(current_path).to eq(new_user_session_path) + fill_in "Username", with: regular_user.login + fill_in "Password", with: regular_user_password + click_on "Login" + end + + context "user has no roles" do + let!(:teams) { create_list(:team, 10) } + + it "shows no teams" do + visit teams_path + + expect(page).to have_content('No teams have been found') + end + end + + context "user has some roles" do + let!(:team) { create(:team, name: "Hufflepuff") } + let!(:another_team) { create(:team, name: "Slytherin") } + let!(:team_role) { create(:team_role, user: regular_user, team: team) } + let!(:another_role) { create(:team_role, user: regular_user, team: another_team) } + let!(:other_team) { create(:team, name: "Death Eaters") } + let!(:other_user_role) { create(:team_role, team: other_team) } + + it "only shows user's teams" do + visit teams_path + + table = find('.resource_table') + expect(table.all('tbody tr').count).to eq 2 + expect(table).to have_content(team_role.team.name) + expect(table).to have_content(another_role.team.name) + end + + it "searches only user's teams" do + visit teams_path + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'Hufflepuff' + click_on "Go" + end + + table = find('.resource_table') + expect(table.all('tbody tr').count).to eq 1 + expect(table).to have_content(team_role.team.name) + + within(controls) do + fill_in "Search", with: 'Death Eaters' + click_on "Go" + end + + expect(page).to have_content('No teams have been found') + end + end + end +end diff --git a/spec/system/users/edit_user_spec.rb b/spec/system/users/edit_user_spec.rb index 16480092f..d07661869 100644 --- a/spec/system/users/edit_user_spec.rb +++ b/spec/system/users/edit_user_spec.rb @@ -13,7 +13,7 @@ end describe "form elements" do - context "when user does not have cloud and biling IDs" do + context "when user does not have a cloud ID" do let(:user) { create(:user) } it "contains expected fields" do @@ -22,51 +22,33 @@ expect(form).to have_field("Name", with: user.name) expect(form).to have_field("Cloud User ID") expect(form.find_field("Cloud User ID").text).to be_blank - expect(form.find_field("Project ID").text).to be_blank - expect(form.find_field("Billing Account ID").text).to be_blank end - it "contains the expected hints" do + it "contains the expected hint" do visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") - hints = [ - { label: "Cloud User ID", hint_text: /cloud user ID will be updated automatically/ }, - { label: "Project ID", hint_text: /project ID will be updated automatically/ }, - { label: "Billing Account ID", hint_text: /billing account ID will be updated automatically/ }, - ] - hints.each do |hint| - field = form.find_field(hint[:label]) - expect(field).to have_sibling(".hint") - expect(field.sibling(".hint")).to have_text(hint[:hint_text]) - end + field = form.find_field("Cloud User ID") + expect(field).to have_sibling(".hint") + expect(field.sibling(".hint")).to have_text(/cloud user ID will be updated automatically/) end end - context "when user has cloud and biling IDs" do - let(:user) { create(:user, :with_openstack_details) } + context "when user has a cloud ID" do + let(:user) { create(:user, :with_openstack_account) } it "contains expected fields" do visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") expect(form).to have_field("Name", with: user.name) expect(form).to have_field("Cloud User ID", with: user.cloud_user_id) - expect(form).to have_field("Project ID", with: user.project_id) - expect(form).to have_field("Billing Account ID", with: user.billing_acct_id) end - it "contains the expected hints" do + it "contains the expected hint" do visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") - hints = [ - { label: "Cloud User ID", hint_text: /Changing the user's cloud user ID/ }, - { label: "Project ID", hint_text: /Changing the user's project ID/ }, - { label: "Billing Account ID", hint_text: /Changing the user's billing account ID/ }, - ] - hints.each do |hint| - field = form.find_field(hint[:label]) - expect(field).to have_sibling(".hint") - expect(field.sibling(".hint")).to have_text(hint[:hint_text]) - end + field = form.find_field("Cloud User ID") + expect(field).to have_sibling(".hint") + expect(field.sibling(".hint")).to have_text(/Changing the user's cloud user ID/) end end end @@ -77,39 +59,34 @@ context "with valid values" do it "updates the expected fields" do expect(user.cloud_user_id).to be_nil - expect(user.project_id).to be_nil - expect(user.billing_acct_id).to be_nil visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") form.fill_in "Cloud User ID", with: "my new cloud user id" - form.fill_in "Project ID", with: "my new project id" form.click_on "Update User" expect(page).to have_text "Successfully updated user" visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") expect(form).to have_field("Cloud User ID", with: "my new cloud user id") - expect(form).to have_field("Project ID", with: "my new project id") - expect(form).to have_field("Billing Account ID", with: "") end end context "with invalid values" do it "displays the errors and keeps the user's input" do - expect(user.project_id).to be_nil + expect(user.cloud_user_id).to be_nil visit edit_user_path(user) form = find("form[id='edit_user_#{user.id}']") form.fill_in "Name", with: "" - form.fill_in "Project ID", with: "my new project id" + form.fill_in "Cloud User ID", with: "my new project id" form.click_on "Update User" expect(page).to have_text "Unable to update user" form = find("form[id='edit_user_#{user.id}']") expect(form).to have_field("Name", with: "") expect(form.find_field("Name")).to have_sibling(".error", text: "can't be blank") - expect(form).to have_field("Project ID", with: "my new project id") + expect(form).to have_field("Cloud User ID", with: "my new project id") end end end diff --git a/spec/system/users/resource_table_spec.rb b/spec/system/users/resource_table_spec.rb index 6273fa28c..0a8068f61 100644 --- a/spec/system/users/resource_table_spec.rb +++ b/spec/system/users/resource_table_spec.rb @@ -195,7 +195,6 @@ end it "respects both sort order and pagination" do - #visit users_path visit users_path(direction: :asc, sort: :name) table = find('.resource_table') @@ -224,10 +223,10 @@ describe "searching" do before(:each) do - # Create enough users to fill an entire page. They will have login's + # Create enough users to fill an entire page. They will have logins # such as `user-1`, `user-2`, etc.. create_list(:user, items_per_page) - # Create a couple of users with login's that sort after the earlier + # Create a couple of users with logins that sort after the earlier # users. They will not be displayed on the initial page load. create(:user, login: 'zzz-wanted-1') create(:user, login: 'zzz-wanted-2') @@ -253,5 +252,33 @@ expect(table).to have_content('zzz-wanted-2') expect(table.all('tbody tr').count).to eq 2 end + + context 'users have teams' do + let!(:team) { create(:team, name: "Hufflepuff") } + let!(:team_role) { create(:team_role, team: team) } + let!(:another_role) { create(:team_role, team: team) } + + it "allows searching for users by team name" do + visit users_path + + controls = find('.search_controls') + within(controls) do + fill_in "Search", with: 'Hufflepuff' + click_on "Go" + end + + table = find('.resource_table') + expect(table.all('tbody tr').count).to eq 2 + expect(table).to have_content(team_role.user.login) + expect(table).to have_content(another_role.user.login) + + within(controls) do + fill_in "Search", with: 'Slytherin' + click_on "Go" + end + + expect(page).to have_content('No users have been found') + end + end end end