diff --git a/app/graphql/mutations/users/delete.rb b/app/graphql/mutations/users/delete.rb new file mode 100644 index 00000000..e5374d3d --- /dev/null +++ b/app/graphql/mutations/users/delete.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Users + class Delete < BaseMutation + description 'Delete an existing user.' + + field :user, Types::UserType, null: true, description: 'The deleted user.' + + argument :user_id, Types::GlobalIdType[::User], required: true, + description: 'The user to delete.' + + def resolve(user_id:) + user = SagittariusSchema.object_from_id(user_id) + + if user.nil? + return { user: nil, + errors: [create_error(:user_not_found, 'Invalid user')] } + end + + ::Users::DeleteService.new( + current_authentication, + user + ).execute.to_mutation_response(success_key: :user) + end + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index a829b40c..cb92812a 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -35,6 +35,7 @@ class MutationType < Types::BaseObject mount_mutation Mutations::Users::Mfa::BackupCodes::Rotate mount_mutation Mutations::Users::Mfa::Totp::GenerateSecret mount_mutation Mutations::Users::Mfa::Totp::ValidateSecret + mount_mutation Mutations::Users::Delete mount_mutation Mutations::Users::EmailVerification mount_mutation Mutations::Users::Login mount_mutation Mutations::Users::Logout diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 0c4edb65..ccb17ae3 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -39,6 +39,7 @@ class AuditEvent < ApplicationRecord email_verified: 35, password_reset_requested: 36, password_reset: 37, + user_deleted: 38, }.with_indifferent_access # rubocop:disable Lint/StructNewOverride diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 7e22c40b..8f6e7ada 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -11,6 +11,7 @@ class UserPolicy < BasePolicy enable :read_user_identity enable :update_attachment_avatar enable :read_email + enable :delete_user end rule { user_is_self }.policy do diff --git a/app/services/users/delete_service.rb b/app/services/users/delete_service.rb new file mode 100644 index 00000000..4a7b12b8 --- /dev/null +++ b/app/services/users/delete_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Users + class DeleteService + include Sagittarius::Database::Transactional + + attr_reader :current_authentication, :user + + def initialize(current_authentication, user) + @current_authentication = current_authentication + @user = user + end + + def execute + unless Ability.allowed?(current_authentication, :delete_user, user) + return ServiceResponse.error(message: 'Missing permission', error_code: :missing_permission) + end + + transactional do |t| + user.delete + + if user.persisted? + t.rollback_and_return! ServiceResponse.error( + message: 'Failed to delete user', + error_code: :invalid_user, + details: user.errors + ) + end + + AuditService.audit( + :user_deleted, + author_id: current_authentication.user.id, + entity: user, + target: AuditEvent::GLOBAL_TARGET, + details: {} + ) + + ServiceResponse.success(message: 'Deleted user', payload: user) + end + end + end +end diff --git a/docs/graphql/mutation/usersdelete.md b/docs/graphql/mutation/usersdelete.md new file mode 100644 index 00000000..1f2d8d2a --- /dev/null +++ b/docs/graphql/mutation/usersdelete.md @@ -0,0 +1,20 @@ +--- +title: usersDelete +--- + +Delete an existing user. + +## Arguments + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `userId` | [`UserID!`](../scalar/userid.md) | The user to delete. | + +## Fields + +| Name | Type | Description | +|------|------|-------------| +| `clientMutationId` | [`String`](../scalar/string.md) | A unique identifier for the client performing the mutation. | +| `errors` | [`[Error!]!`](../object/error.md) | Errors encountered during execution of the mutation. | +| `user` | [`User`](../object/user.md) | The deleted user. | diff --git a/spec/graphql/mutations/users/delete_spec.rb b/spec/graphql/mutations/users/delete_spec.rb new file mode 100644 index 00000000..a768012c --- /dev/null +++ b/spec/graphql/mutations/users/delete_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Mutations::Users::Delete do + it { expect(described_class.graphql_name).to eq('UsersDelete') } +end diff --git a/spec/requests/graphql/mutation/users/delete_spec.rb b/spec/requests/graphql/mutation/users/delete_spec.rb new file mode 100644 index 00000000..8ef3f1ec --- /dev/null +++ b/spec/requests/graphql/mutation/users/delete_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'usersDelete Mutation' do + include GraphqlHelpers + + let(:mutation) do + <<~QUERY + mutation($input: UsersDeleteInput!) { + usersDelete(input: $input) { + #{error_query} + user { + id + username + admin + } + } + } + QUERY + end + + let(:input) do + { + userId: user.to_global_id.to_s, + } + end + + let(:user) { create(:user) } + let(:variables) { { input: input } } + let(:current_user) { create(:user, :admin) } + + before do + post_graphql mutation, variables: variables, current_user: current_user + end + + it 'deletes user' do + expect(graphql_data_at(:users_delete, :user, :id)).to be_present + expect(SagittariusSchema.object_from_id(graphql_data_at(:users_delete, :user, :id))).to be_nil + + is_expected.to create_audit_event( + :user_deleted, + author_id: current_user.id, + entity_type: 'User', + entity_id: user.id, + details: {}, + target_type: 'global', + target_id: 0 + ) + end + + context 'when current user lacks permission' do + let(:current_user) { create(:user) } + + it 'returns a missing permission error' do + expect(graphql_data_at(:users_delete, :user)).to be_nil + expect(graphql_data_at(:users_delete, :errors, :error_code)).to include('MISSING_PERMISSION') + + expect(User.exists?(user.id)).to be true + is_expected.not_to create_audit_event(:user_deleted) + end + end +end diff --git a/spec/services/users/delete_service_spec.rb b/spec/services/users/delete_service_spec.rb new file mode 100644 index 00000000..b2a989b7 --- /dev/null +++ b/spec/services/users/delete_service_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe Users::DeleteService do + subject(:service_response) do + described_class.new(create_authentication(current_user), user).execute + end + + let(:user) { create(:user) } + let(:current_user) { create(:user, :admin) } + + it 'deletes the user successfully' do + expect { service_response }.to change { User.exists?(user.id) }.from(true).to(false) + expect(service_response).to be_success + expect(service_response.payload).to eq(user) + + is_expected.to create_audit_event( + :user_deleted, + author_id: current_user.id, + entity_type: 'User', + entity_id: user.id, + details: {}, + target_type: 'global', + target_id: 0 + ) + end + + context 'when current user lacks permission' do + let(:current_user) { create(:user) } + + it 'returns a missing permission error' do + expect(service_response).not_to be_success + expect(service_response.payload[:error_code]).to eq(:missing_permission) + expect(User.exists?(user.id)).to be true + is_expected.not_to create_audit_event(:user_deleted) + end + end +end