Commit 6ec97a8
Changed files (11)
app
controllers
scim
models
config
spec
app/controllers/scim/v2/users_controller.rb
@@ -42,7 +42,7 @@ module Scim
private
def user_params
- params.permit(:schemas, :userName)
+ params.permit(:schemas, :userName, :locale, :timezone)
end
def repository(container = Spank::IOC)
app/models/scim/user.rb
@@ -0,0 +1,41 @@
+
+module SCIM
+ class User
+ include ActiveModel::Model
+ attr_accessor :id, :schemas, :userName, :name, :locale, :timezone, :password
+
+ validate :must_be_user_schema
+ validates :id, format: { with: /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/ }, if: proc { |x| x.id.present? }
+ validates :locale, presence: true, inclusion: I18n.available_locales.map(&:to_s)
+ validates :timezone, presence: true, inclusion: ::User::VALID_TIMEZONES
+ validates :userName, presence: true, email: true
+
+ def save!
+ validate!
+ if id.present?
+ user = ::User.find_by!(uuid: id)
+ ensure_password_update_is_allowed!(user) if password.present?
+ user.update!(to_h)
+ else
+ user = ::User.create!(to_h(password: password || SecureRandom.hex(32)))
+ end
+ user
+ end
+
+ private
+
+ def must_be_user_schema
+ errors.add(:schemas, "is invalid") unless schemas == [SCIM::Schema::USER]
+ end
+
+ def ensure_password_update_is_allowed!(user)
+ raise StandardError.new(I18n.t('.password_update_not_permitted')) unless Current.user == user
+ end
+
+ def to_h(extra = {})
+ x = { email: userName, locale: locale, timezone: timezone }
+ x[:password] = password if password.present?
+ x.merge(extra)
+ end
+ end
+end
app/models/scim/user_mapper.rb
@@ -13,6 +13,8 @@ module SCIM
x.created_at = user.created_at
x.updated_at = user.updated_at
x.location = @url_helpers.scim_v2_user_url(user)
+ x.locale = user.locale
+ x.timezone = user.timezone
x.version = user.lock_version
x.emails do |y|
y.add(user.email, primary: true)
app/models/scim/user_repository.rb
@@ -9,27 +9,33 @@ module SCIM
end
def find!(id)
- mapper.map_from(User.find_by!(uuid: id))
+ mapper.map_from(::User.find_by!(uuid: id))
end
def create!(params)
password = SecureRandom.hex(32)
mapper.map_from(
- User.create!(
+ ::User.create!(
email: params[:userName],
- password: password
+ password: password,
+ locale: params[:locale],
+ timezone: params[:timezone]
)
)
end
def update!(id, params)
- user = User.find_by!(uuid: id)
- user.update!(email: params[:userName])
+ user = ::User.find_by!(uuid: id)
+ user.update!(
+ email: params[:userName],
+ locale: params[:locale],
+ timezone: params[:timezone]
+ )
mapper.map_from(user)
end
def destroy!(id)
- User.find_by!(uuid: id).destroy!
+ ::User.find_by!(uuid: id).destroy!
end
end
end
app/models/user.rb
@@ -1,13 +1,14 @@
# frozen_string_literal: true
class User < ApplicationRecord
+ VALID_TIMEZONES = ActiveSupport::TimeZone::MAPPING.values
has_secure_password
has_many :sessions, foreign_key: "user_id", class_name: UserSession.name
validates :email, presence: true, email: true, uniqueness: {
case_sensitive: false
}
- validates :timezone, inclusion: ActiveSupport::TimeZone::MAPPING.values
+ validates :timezone, inclusion: VALID_TIMEZONES
validates :locale, inclusion: { in: proc { I18n.available_locales.map(&:to_s) } }
after_initialize do
config/initializers/configuration.rb
@@ -2,3 +2,6 @@
config = Rails.application.config
config.x.jwt.private_key = OpenSSL::PKey::RSA.new(2048)
+
+I18n.available_locales = [:en, :es, :fr, :ja, :ko]
+I18n.default_locale = :en
config/locales/en.yml
@@ -1,5 +1,14 @@
---
en:
+ '':
+ password_update_not_permitted: Password update not permitted
+ activemodel:
+ attributes:
+ scim/user:
+ locale: locale
+ password: password
+ schemas: schemas
+ userName: userName
application:
navbar:
clients: Clients
config/i18n-tasks.yml
@@ -97,8 +97,9 @@ search:
# - '{devise,simple_form}.*'
## Consider these keys used:
-# ignore_unused:
-# - 'activerecord.attributes.*'
+ignore_unused:
+- 'activemodel.attributes.*'
+- 'activerecord.attributes.*'
# - '{devise,kaminari,will_paginate}.*'
# - 'simple_form.{yes,no}'
# - 'simple_form.{placeholders,hints,labels}.*'
spec/factories/scim.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :scim_user, class: SCIM::User do
+ schemas { ["urn:ietf:params:scim:schemas:core:2.0:User"] }
+ userName { FFaker::Internet.email }
+ locale { I18n.available_locales.sample.to_s }
+ timezone { User::VALID_TIMEZONES.sample }
+ end
+end
spec/models/scim/user_spec.rb
@@ -0,0 +1,114 @@
+require 'rails_helper'
+
+RSpec.describe SCIM::User do
+ describe "#valid?" do
+ specify { expect(build(:scim_user)).to be_valid }
+ specify { expect(build(:scim_user, id: 1)).to be_invalid }
+
+ specify do
+ subject = build(:scim_user, schemas: ["urn:ietf:params:scim:schemas:core:2.0:Blah"])
+ expect(subject).not_to be_valid
+ expect(subject.errors[:schemas]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, userName: nil)
+ expect(subject).not_to be_valid
+ expect(subject.errors[:userName]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, userName: 'notanemail')
+ expect(subject).not_to be_valid
+ expect(subject.errors[:userName]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, locale: '')
+ expect(subject).not_to be_valid
+ expect(subject.errors[:locale]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, locale: 'de')
+ expect(subject).not_to be_valid
+ expect(subject.errors[:locale]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, timezone: '')
+ expect(subject).not_to be_valid
+ expect(subject.errors[:timezone]).to be_present
+ end
+
+ specify do
+ subject = build(:scim_user, timezone: 'etc/unknown')
+ expect(subject).not_to be_valid
+ expect(subject.errors[:timezone]).to be_present
+ end
+ end
+
+ describe "#save!" do
+ context "when the user is new" do
+ let(:current_user) { create(:user) }
+ before { allow(Current).to receive(:user).and_return(current_user) }
+
+ context "when creating a user" do
+ subject { build(:scim_user) }
+
+ before { @result = subject.save! }
+
+ specify { expect(@result).to be_persisted }
+ specify { expect(@result.uuid).to be_present }
+ specify { expect(@result.email).to eql(subject.userName) }
+ specify { expect(@result.locale).to eql(subject.locale) }
+ specify { expect(@result.timezone).to eql(subject.timezone) }
+ specify { expect(@result.password_digest).to be_present }
+ end
+ end
+
+ context "when one user is updating another user" do
+ subject { build(:scim_user, id: other_user.to_param) }
+ let(:current_user) { create(:user) }
+ let(:other_user) { create(:user) }
+
+ before { allow(Current).to receive(:user).and_return(current_user) }
+ before { @result = subject.save! }
+
+ specify { expect(@result.uuid).to eql(other_user.uuid) }
+ specify { expect(@result.email).to eql(subject.userName) }
+ specify { expect(@result.locale).to eql(subject.locale) }
+ specify { expect(@result.timezone).to eql(subject.timezone) }
+ end
+
+ context "when one user attempts to change the password of another user" do
+ subject { build(:scim_user, id: other_user.to_param, password: generate(:password)) }
+ let(:current_user) { create(:user) }
+ let(:other_user) { create(:user) }
+
+ before { allow(Current).to receive(:user).and_return(current_user) }
+ specify { expect { subject.save! }.to raise_error(StandardError) }
+ end
+
+ context "when a user changes their own password" do
+ subject { build(:scim_user, id: current_user.to_param, password: password) }
+ let!(:current_user) { create(:user) }
+ let(:password) { generate(:password) }
+
+ before { freeze_time }
+ before { allow(Current).to receive(:user).and_return(current_user) }
+ before { @result = subject.save! }
+
+ specify { expect(@result.authenticate(password)).to be_truthy }
+ end
+ end
+
+ describe "#humanAttributeName" do
+ subject { described_class }
+
+ specify { expect(subject.human_attribute_name('userName')).to eql('userName') }
+ specify { expect(subject.human_attribute_name('schemas')).to eql('schemas') }
+ specify { expect(subject.human_attribute_name('locale')).to eql('locale') }
+ specify { expect(subject.human_attribute_name('password')).to eql('password') }
+ end
+end
spec/requests/scim/v2/users_spec.rb
@@ -14,42 +14,41 @@ describe '/scim/v2/users' do
describe "POST /scim/v2/users" do
context "when a valid request is sent" do
let(:email) { generate(:email) }
+ let(:locale) { 'en' }
+ let(:timezone) { 'Etc/UTC' }
+ let(:body) { { schemas: [Scim::Shady::Schemas::USER], userName: email, locale: locale, timezone: timezone } }
- it 'creates a new user' do
- body = { schemas: [Scim::Shady::Schemas::USER], userName: email }
-
- post '/scim/v2/users', params: body.to_json, headers: headers
-
- expect(response).to have_http_status(:created)
- expect(response.headers['Content-Type']).to eql('application/scim+json')
- expect(response.headers['Location']).to be_present
- expect(response.body).to be_present
-
- json = JSON.parse(response.body, symbolize_names: true)
- expect(json[:schemas]).to match_array([Scim::Shady::Schemas::USER])
- expect(json[:id]).to be_present
- expect(json[:userName]).to eql(email)
- expect(json[:meta][:resourceType]).to eql('User')
- expect(json[:meta][:created]).to be_present
- expect(json[:meta][:lastModified]).to be_present
- expect(json[:meta][:version]).to be_present
- expect(json[:meta][:location]).to be_present
- end
+ before { post '/scim/v2/users', params: body.to_json, headers: headers }
+
+ specify { expect(response).to have_http_status(:created) }
+ specify { expect(response.headers['Content-Type']).to eql('application/scim+json') }
+ specify { expect(response.headers['Location']).to be_present }
+ specify { expect(response.body).to be_present }
+ let(:json) { JSON.parse(response.body, symbolize_names: true) }
+ specify { expect(json[:schemas]).to match_array([Scim::Shady::Schemas::USER]) }
+ specify { expect(json[:id]).to be_present }
+ specify { expect(json[:userName]).to eql(email) }
+ specify { expect(json[:meta][:resourceType]).to eql('User') }
+ specify { expect(json[:meta][:created]).to be_present }
+ specify { expect(json[:meta][:lastModified]).to be_present }
+ specify { expect(json[:meta][:version]).to be_present }
+ specify { expect(json[:meta][:location]).to be_present }
+ specify { expect(json[:locale]).to eql(locale) }
+ specify { expect(json[:timezone]).to eql(timezone) }
end
context "when a duplicate email is specified" do
let(:other_user) { create(:user) }
- let(:request_body) do
- { schemas: [Scim::Shady::Schemas::USER], userName: other_user.email }
- end
+ let(:request_body) { attributes_for(:scim_user, userName: other_user.email) }
before { post '/scim/v2/users', params: request_body.to_json, headers: headers }
specify { expect(response).to have_http_status(:bad_request) }
- specify { expect(JSON.parse(response.body, symbolize_names: true)[:schemas]).to match_array(['urn:ietf:params:scim:api:messages:2.0:Error']) }
- specify { expect(JSON.parse(response.body, symbolize_names: true)[:scimType]).to eql('uniqueness') }
- specify { expect(JSON.parse(response.body, symbolize_names: true)[:detail]).to be_instance_of(String) }
- specify { expect(JSON.parse(response.body, symbolize_names: true)[:status]).to eql('400') }
+ let(:json) { JSON.parse(response.body, symbolize_names: true) }
+ specify { expect(json[:schemas]).to match_array(['urn:ietf:params:scim:api:messages:2.0:Error']) }
+ specify { expect(json[:scimType]).to eql('uniqueness') }
+ specify { expect(json[:detail]).to be_instance_of(String) }
+ specify { expect(json[:status]).to eql('400') }
end
end
@@ -105,44 +104,42 @@ describe '/scim/v2/users' do
end
describe "GET /scim/v2/users" do
- it 'returns an empty set of results' do
- get "/scim/v2/users?attributes=userName", headers: headers
-
- expect(response).to have_http_status(:ok)
- expect(response.headers['Content-Type']).to eql('application/scim+json')
- expect(response.body).to be_present
-
- json = JSON.parse(response.body, symbolize_names: true)
- expect(json[:schemas]).to match_array([Scim::Shady::Messages::LIST_RESPONSE])
- expect(json[:totalResults]).to be_zero
- expect(json[:Resources]).to be_empty
- end
+ before { get "/scim/v2/users?attributes=userName", headers: headers }
+
+ specify { expect(response).to have_http_status(:ok) }
+ specify { expect(response.headers['Content-Type']).to eql('application/scim+json') }
+ specify { expect(response.body).to be_present }
+ let(:json) { JSON.parse(response.body, symbolize_names: true) }
+ specify { expect(json[:schemas]).to match_array([Scim::Shady::Messages::LIST_RESPONSE]) }
+ specify { expect(json[:totalResults]).to be_zero }
+ specify { expect(json[:Resources]).to be_empty }
end
describe "PUT /scim/v2/users" do
let(:user) { create(:user) }
let(:new_email) { FFaker::Internet.email }
-
- it 'updates the user' do
- body = { schemas: [Scim::Shady::Schemas::USER], userName: new_email }
- put "/scim/v2/users/#{user.uuid}", headers: headers, params: body.to_json
-
- expect(response).to have_http_status(:ok)
- expect(response.headers['Content-Type']).to eql('application/scim+json')
- expect(response.headers['Location']).to eql(scim_v2_user_url(user))
- expect(response.body).to be_present
-
- json = JSON.parse(response.body, symbolize_names: true)
- expect(json[:schemas]).to match_array([Scim::Shady::Schemas::USER])
- expect(json[:id]).to be_present
- expect(json[:userName]).to eql(new_email)
- expect(json[:meta][:resourceType]).to eql('User')
- expect(json[:meta][:created]).to be_present
- expect(json[:meta][:lastModified]).to be_present
- expect(json[:meta][:version]).to be_present
- expect(json[:meta][:location]).to be_present
- expect(json[:emails]).to match_array([value: new_email, type: 'work', primary: true])
- end
+ let(:locale) { 'ja' }
+ let(:timezone) { 'America/Denver' }
+ let(:body) { { schemas: [Scim::Shady::Schemas::USER], userName: new_email, locale: locale, timezone: timezone } }
+
+ before { put "/scim/v2/users/#{user.uuid}", headers: headers, params: body.to_json }
+
+ specify { expect(response).to have_http_status(:ok) }
+ specify { expect(response.headers['Content-Type']).to eql('application/scim+json') }
+ specify { expect(response.headers['Location']).to eql(scim_v2_user_url(user)) }
+ specify { expect(response.body).to be_present }
+ let(:json) { JSON.parse(response.body, symbolize_names: true) }
+ specify { expect(json[:schemas]).to match_array([Scim::Shady::Schemas::USER]) }
+ specify { expect(json[:id]).to be_present }
+ specify { expect(json[:userName]).to eql(new_email) }
+ specify { expect(json[:meta][:resourceType]).to eql('User') }
+ specify { expect(json[:meta][:created]).to be_present }
+ specify { expect(json[:meta][:lastModified]).to be_present }
+ specify { expect(json[:meta][:version]).to be_present }
+ specify { expect(json[:meta][:location]).to be_present }
+ specify { expect(json[:emails]).to match_array([value: new_email, type: 'work', primary: true]) }
+ specify { expect(json[:locale]).to eql(locale) }
+ specify { expect(json[:timezone]).to eql(timezone) }
end
describe "DELETE /scim/v2/users/:id" do