Merge pull request #4950 from opf/feature/24062/user-create-api

[24062] Add POST api/v3/users
pull/4963/head
Markus Kahl 8 years ago committed by GitHub
commit f3e7210813
  1. 52
      app/contracts/users/base_contract.rb
  2. 58
      app/contracts/users/create_contract.rb
  3. 28
      app/controllers/concerns/user_invitation.rb
  4. 106
      app/services/users/create_user_service.rb
  5. 6
      config/locales/en.yml
  6. 5
      lib/api/utilities/property_name_converter.rb
  7. 66
      lib/api/v3/users/create_user.rb
  8. 19
      lib/api/v3/users/user_representer.rb
  9. 13
      lib/api/v3/users/users_api.rb
  10. 83
      spec/contracts/users/create_contract_spec.rb
  11. 4
      spec/lib/api/v3/users/user_representer_spec.rb
  12. 206
      spec/requests/api/v3/user/create_user_resource_spec.rb
  13. 0
      spec/requests/api/v3/user/user_resource_spec.rb
  14. 98
      spec/services/users/create_user_service_spec.rb

@ -0,0 +1,52 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'model_contract'
module Users
class BaseContract < ::ModelContract
attribute :type
attribute :login
attribute :firstname
attribute :lastname
attribute :name
attribute :mail
attribute :status
def initialize(user, current_user)
super(user)
@current_user = current_user
end
private
attr_reader :current_user
end
end

@ -0,0 +1,58 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'users/base_contract'
module Users
class CreateContract < BaseContract
validate :user_allowed_to_add
attribute :password do
# when user's are created as 'active', a password must be set
errors.add :password, :blank if model.active? && model.password.blank?
end
attribute :status do
unless model.active? || model.invited?
# New users may only have these two statuses
errors.add :status, :invalid_on_create
end
end
private
##
# Users can only be created by Admins
def user_allowed_to_add
unless current_user.admin?
errors.add :base, :error_unauthorized
end
end
end
end

@ -43,9 +43,6 @@ module UserInvitation
##
# Creates an invited user with the given email address.
# If no first and last is given it will default to 'OpenProject User'
# for the first name and 'To-be' for the last name.
# The default login is the email address.
#
# @param email E-Mail address the invitation is sent to.
# @param login User's login (optional)
@ -58,19 +55,32 @@ module UserInvitation
# on the returned user will yield `false`. Check for validation errors
# in that case.
def invite_new_user(email:, login: nil, first_name: nil, last_name: nil)
placeholder = placeholder_name(email)
user = User.new login: login || email,
mail: email,
firstname: first_name || placeholder.first,
lastname: last_name || placeholder.last,
user = User.new mail: email,
login: login,
firstname: first_name,
lastname: last_name,
status: Principal::STATUSES[:invited]
assign_user_attributes(user)
yield user if block_given?
invite_user! user
end
##
# For the given user with at least the mail attribute set,
# derives login and first name
#
# The default login is the email address.
def assign_user_attributes(user)
placeholder = placeholder_name(user.mail)
user.login = user.login.presence || user.mail
user.firstname = user.firstname.presence || placeholder.first
user.lastname = user.lastname.presence || placeholder.last
end
##
# Sends a new invitation to the user with a new token.
#

@ -0,0 +1,106 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'work_packages/create_contract'
require 'concerns/user_invitation'
module Users
class CreateUserService
include Concerns::Contracted
self.contract = Users::CreateContract
attr_reader :current_user
def initialize(current_user:)
@current_user = current_user
end
def call(new_user)
User.execute_as current_user do
create(new_user)
end
end
private
def create(new_user)
initialize_contract(new_user)
return create_regular(new_user) unless new_user.invited?
# As we're basing on the user's mail, this parameter is required
# before we're able to validate the contract or user
if new_user.mail.blank?
contract.errors.add :mail, :blank
build_result(new_user, contract.errors)
else
create_invited(new_user)
end
end
def build_result(result, errors)
success = result.is_a?(User) && errors.empty?
ServiceResult.new(success: success, errors: errors, result: result)
end
##
# Create regular user
def create_regular(new_user)
result, errors = validate_and_save(new_user)
ServiceResult.new(success: result, errors: errors, result: new_user)
end
##
# User creation flow for users that are invited.
# Re-uses UserInvitation and thus avoids +validate_and_save+
def create_invited(new_user)
# Assign values other than mail to new_user
::UserInvitation.assign_user_attributes new_user
# Check contract validity before moving to UserInvitation
if !contract.validate
build_result(new_user, contract.errors)
end
invite_user! new_user
end
def invite_user!(new_user)
invited = ::UserInvitation.invite_user! new_user
new_user.errors.add :base, I18n.t(:error_can_not_invite_user) unless invited.is_a? User
build_result(invited, new_user.errors)
end
def initialize_contract(new_user)
self.contract = self.class.contract.new(new_user, current_user)
end
end
end

@ -223,7 +223,10 @@ en:
from:
error_not_found: "work package in `from` position not found or not visible"
error_readonly: "an existing relation's `from` link is immutable"
"users/base_contract":
attributes:
status:
invalid_on_create: "is not a valid status for new users."
activerecord:
attributes:
announcements:
@ -777,6 +780,7 @@ en:
error_can_not_delete_custom_field: "Unable to delete custom field"
error_can_not_delete_type: "This type contains work packages and cannot be deleted."
error_can_not_delete_standard_type: "Standard types cannot be deleted."
error_can_not_invite_user: "Failed to send invitation to user."
error_can_not_remove_role: "This role is in use and cannot be deleted."
error_can_not_reopen_work_package_on_closed_version: "A work package assigned to a closed version cannot be reopened"
error_check_user_and_role: "Please choose a user and a role."

@ -56,8 +56,9 @@ module API
remaining_hours: 'remainingTime',
spent_hours: 'spentTime',
subproject: 'subprojectId',
relation_type: 'type'
}
relation_type: 'type',
mail: 'email'
}.freeze
# Converts the attribute name as refered to by ActiveRecord to a corresponding API-conform
# attribute name:

@ -0,0 +1,66 @@
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'api/v3/users/user_representer'
require 'users/create_user_service'
module API
module V3
module Users
module CreateUser
extend Grape::API::Helpers
##
# Call the user create service for the current request
# and return the service result API representation
def create_user(request_body, current_user)
payload = ::API::V3::Users::UserRepresenter.create(User.new, current_user: current_user)
new_user = payload.from_hash(request_body)
result = call_service(new_user, current_user)
represent_service_result(result, current_user)
end
private
def represent_service_result(result, current_user)
if result.success?
status 201
::API::V3::Users::UserRepresenter.create(result.result, current_user: current_user)
else
fail ::API::Errors::ErrorBase.create_and_merge_errors(result.errors)
end
end
def call_service(new_user, current_user)
create_service = ::Users::CreateUserService.new(current_user: current_user)
create_service.call(new_user)
end
end
end
end
end

@ -92,11 +92,15 @@ module API
render_nil: true
property :name,
render_nil: true
property :email,
getter: -> (*) { mail },
property :mail,
as: :email,
render_nil: true,
# FIXME: remove the "is_a?" as soon as we have a dedicated group representer
if: -> (*) { self.is_a?(User) && !pref.hide_mail }
getter: ->(*) {
if is_a?(User) && !pref.hide_mail
mail
end
}
property :avatar,
getter: -> (*) { avatar_url(represented) },
render_nil: true,
@ -111,8 +115,17 @@ module API
getter: -> (*) { datetime_formatter.format_datetime(represented.updated_on) }
property :status,
getter: -> (*) { status_name },
setter: -> (value, *) { self.status = User::STATUSES[value.to_sym] },
render_nil: true
# Write-only properties
property :password,
getter: -> (*) { nil },
render_nil: false,
setter: -> (value, *) {
self.password = self.password_confirmation = value
}
def _type
'User'
end

@ -44,9 +44,22 @@ module API
fail ::API::Errors::InvalidUserStatusTransition
end
end
def allow_only_admin
unless current_user.admin?
fail ::API::Errors::Unauthorized
end
end
end
resources :users do
helpers ::API::V3::Users::CreateUser
post do
allow_only_admin
create_user(request_body, current_user)
end
params do
requires :id, desc: 'User\'s id'
end

@ -0,0 +1,83 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
#++
require 'spec_helper'
describe Users::CreateContract do
let(:user) { FactoryGirl.build_stubbed(:user) }
subject(:contract) { described_class.new(user, current_user) }
def expect_valid(valid, symbols = {})
expect(contract.validate).to eq(valid)
symbols.each do |key, arr|
expect(contract.errors.symbols_for(key)).to match_array arr
end
end
shared_examples 'is valid' do
it 'is valid' do
expect_valid(true)
end
end
context 'when admin' do
let(:current_user) { FactoryGirl.build_stubbed(:admin) }
it_behaves_like 'is valid'
describe 'requires a password set when active' do
before do
user.password = nil
user.activate
end
it 'is invalid' do
expect_valid(false, password: %i(blank))
end
context 'when password is set' do
before do
user.password = user.password_confirmation = 'password!password!'
end
it_behaves_like 'is valid'
end
end
end
context 'when not admin' do
let(:current_user) { FactoryGirl.build_stubbed(:user) }
it 'is invalid' do
expect_valid(false, base: %i(error_unauthorized))
end
end
end

@ -58,8 +58,8 @@ describe ::API::V3::Users::UserRepresenter do
let(:preference) { FactoryGirl.build(:user_preference, hide_mail: true) }
let(:user) { FactoryGirl.build_stubbed(:user, status: 1, preference: preference) }
it 'hides the users E-Mail address' do
is_expected.not_to have_json_path('email')
it 'does not render the users E-Mail address' do
is_expected.to be_json_eql(nil.to_json).at_path('email')
end
end
end

@ -0,0 +1,206 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
require 'spec_helper'
require 'rack/test'
describe ::API::V3::Users::UsersAPI, type: :request do
include API::V3::Utilities::PathHelper
let(:path) { api_v3_paths.users }
let(:user) { FactoryGirl.build(:admin) }
let(:parameters) { {} }
before do
login_as(user)
end
def send_request
post path, params: parameters.to_json, headers: { 'Content-Type' => 'application/json' }
end
let(:errors) { parse_json(response.body)['_embedded']['errors'] }
shared_context 'represents the created user' do |expected_attributes|
it 'returns the represented user' do
send_request
expect(response.status).to eq(201)
expect(response.body).to have_json_type(Object).at_path('_links')
expect(response.body)
.to be_json_eql('User'.to_json)
.at_path('_type')
parameters.merge!(expected_attributes) if expected_attributes
user = User.find_by!(login: parameters.fetch(:login, parameters[:email]))
expect(user.firstname).to eq(parameters[:firstName])
expect(user.lastname).to eq(parameters[:lastName])
expect(user.mail).to eq(parameters[:email])
end
end
describe 'empty request body' do
it 'returns an erroneous response' do
send_request
expect(response.status).to eq(422)
expect(errors.count).to eq(5)
expect(errors.collect { |el| el['_embedded']['details']['attribute'] })
.to contain_exactly('password', 'login', 'firstname', 'lastname', 'email')
expect(response.body)
.to be_json_eql('urn:openproject-org:api:v3:errors:MultipleErrors'.to_json)
.at_path('errorIdentifier')
end
end
describe 'active status' do
let(:status) { 'active' }
let(:password) { 'admin!admin!' }
let(:parameters) {
{
status: status,
login: 'myusername',
firstName: 'Foo',
lastName: 'Bar',
email: 'foobar@example.org',
password: password
}
}
it 'returns the represented user' do
send_request
expect(response.body).not_to have_json_path("_embedded/errors")
expect(response.body).to have_json_type(Object).at_path('_links')
expect(response.body)
.to be_json_eql('User'.to_json)
.at_path('_type')
end
it_behaves_like 'represents the created user'
context 'empty password' do
let(:password) { '' }
it 'marks the password missing and too short' do
send_request
expect(errors.count).to eq(2)
expect(errors.collect { |el| el['_embedded']['details']['attribute'] })
.to match_array %w(password password)
end
end
end
describe 'invited status' do
let(:status) { 'invited' }
let(:invitation_request) {
{
status: status,
email: 'foo@example.org'
}
}
describe 'invitation successful' do
before do
expect(OpenProject::Notifications).to receive(:send) do |event, _|
expect(event).to eq 'user_invited'
end
end
context 'only mail set' do
let(:parameters) { invitation_request }
it_behaves_like 'represents the created user',
firstName: 'foo',
lastName: '@example.org'
it 'sets the other attributes' do
send_request
user = User.find_by!(login: 'foo@example.org')
expect(user.firstname).to eq('foo')
expect(user.lastname).to eq('@example.org')
expect(user.mail).to eq('foo@example.org')
end
end
context 'mail and name set' do
let(:parameters) { invitation_request.merge(firstName: 'First', lastName: 'Last') }
it_behaves_like 'represents the created user'
end
end
context 'missing email' do
let(:parameters) { { status: status } }
it 'marks the mail as missing' do
send_request
expect(response.body)
.to be_json_eql('urn:openproject-org:api:v3:errors:PropertyConstraintViolation'.to_json)
.at_path('errorIdentifier')
expect(response.body)
.to be_json_eql('email'.to_json)
.at_path('_embedded/details/attribute')
end
end
end
describe 'invalid status' do
let(:parameters) { { status: 'blablu' } }
it 'returns an erroneous response' do
send_request
expect(response.status).to eq(422)
expect(errors).not_to be_empty
expect(response.body)
.to be_json_eql('urn:openproject-org:api:v3:errors:MultipleErrors'.to_json)
.at_path('errorIdentifier')
expect(errors.collect { |el| el['message'] })
.to include 'Status is not a valid status for new users.'
end
end
describe 'unauthorized user' do
let(:user) { FactoryGirl.build(:user) }
let(:parameters) { { status: 'invited', email: 'foo@example.org' } }
it 'returns an erroneous response' do
send_request
expect(response.status).to eq(403)
end
end
end

@ -0,0 +1,98 @@
#-- encoding: UTF-8
#-- copyright
# OpenProject is a project management system.
# Copyright (C) 2012-2015 the OpenProject Foundation (OPF)
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License version 3.
#
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
# Copyright (C) 2006-2013 Jean-Philippe Lang
# Copyright (C) 2010-2013 the ChiliProject Team
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# See doc/COPYRIGHT.rdoc for more details.
require 'spec_helper'
describe Users::CreateUserService do
let(:current_user) { FactoryGirl.build_stubbed(:user) }
let(:new_user) { FactoryGirl.build_stubbed(:user) }
let(:instance) { described_class.new(current_user: current_user) }
describe '.contract' do
it 'uses the CreateContract contract' do
expect(described_class.contract).to eql Users::CreateContract
end
end
describe '.new' do
it 'takes a user which is available as a getter' do
expect(instance.current_user).to eql current_user
end
end
describe '#call' do
subject { instance.call(new_user) }
let(:validates) { true }
let(:saves) { true }
before do
allow(new_user).to receive(:save).and_return(saves)
allow_any_instance_of(Users::CreateContract).to receive(:validate).and_return(validates)
end
context 'if contract validates and the user saves' do
it 'is successful' do
expect(subject).to be_success
end
it 'has no errors' do
expect(subject.errors).to be_empty
end
it 'returns the user as a result' do
result = subject.result
expect(result).to be_a User
end
end
context 'if contract does not validate' do
let(:validates) { false }
it 'is unsuccessful' do
expect(subject).to_not be_success
end
end
context 'if user does not save' do
let(:saves) { false }
let(:errors) { double('errors') }
it 'is unsuccessful' do
expect(subject).to_not be_success
end
it "returns the user's errors" do
allow(new_user)
.to receive(:errors)
.and_return errors
expect(subject.errors).to eql errors
end
end
end
end
Loading…
Cancel
Save