Commit 6ec97a8

mo <mo@mokhan.ca>
2018-09-22 23:10:33
extract scim user model
1 parent ec3585b
Changed files (11)
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