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)) # # =>
There are no cluster types available at present.
<% else %> ++ You must be an admin for a team with at least <%= Rails.application.config.cluster_credit_requirement %> credits to create a cluster. +
+ <% end %> +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" : "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