From 99ddb6752f608f25f24aa7b1b2ec9160a523a442 Mon Sep 17 00:00:00 2001 From: Jan Sandbrink Date: Thu, 4 Dec 2025 13:10:51 +0100 Subject: [PATCH] Remove feature toggle for SCIM API This has been part of OpenProject for a few releases already and there is no need to disable it anymore. --- config/initializers/feature_decisions.rb | 4 - config/initializers/menus.rb | 2 +- config/initializers/scimitar.rb | 3 - spec/requests/scim_v2/authentication_spec.rb | 20 +- spec/requests/scim_v2/groups_spec.rb | 809 ++++++------- spec/requests/scim_v2/schemas_spec.rb | 449 ++++---- .../scim_v2/service_provider_config_spec.rb | 86 +- spec/requests/scim_v2/users_spec.rb | 1008 ++++++++--------- 8 files changed, 1068 insertions(+), 1313 deletions(-) diff --git a/config/initializers/feature_decisions.rb b/config/initializers/feature_decisions.rb index 93d03ad64a0..6f5e4232069 100644 --- a/config/initializers/feature_decisions.rb +++ b/config/initializers/feature_decisions.rb @@ -53,10 +53,6 @@ OpenProject::FeatureDecisions.add :oidc_group_sync, description: "Allows to synchronize groups from OpenID Connect providers", force_active: true -OpenProject::FeatureDecisions.add :scim_api, - description: "Enables SCIM API.", - force_active: true - OpenProject::FeatureDecisions.add :beta_widgets, description: "Enables BETA versions of widgets." diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index 894a07709a1..f5f68ed7134 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -588,7 +588,7 @@ Redmine::MenuManager.map :admin_menu do |menu| menu.push :scim_clients, { controller: "/admin/scim_clients", action: "index" }, - if: ->(_) { User.current.admin? && OpenProject::FeatureDecisions.scim_api_active? }, + if: ->(_) { User.current.admin? }, parent: :authentication, caption: ScimClient.model_name.human(count: 2), enterprise_feature: "scim_api" diff --git a/config/initializers/scimitar.rb b/config/initializers/scimitar.rb index ecfa267b9ad..a99fec37d69 100644 --- a/config/initializers/scimitar.rb +++ b/config/initializers/scimitar.rb @@ -43,9 +43,6 @@ Rails.application.config.to_prepare do ) return handle_scim_error(error) end - if !OpenProject::FeatureDecisions.scim_api_active? - return handle_scim_error(Scimitar::AuthenticationError.new) - end warden = request.env["warden"] user = warden.authenticate(scope: :scim_v2) diff --git a/spec/requests/scim_v2/authentication_spec.rb b/spec/requests/scim_v2/authentication_spec.rb index 6476af7b7ad..db464a799bd 100644 --- a/spec/requests/scim_v2/authentication_spec.rb +++ b/spec/requests/scim_v2/authentication_spec.rb @@ -38,7 +38,7 @@ RSpec.describe "SCIM API Authentication" do let(:scim_client) { create(:scim_client, authentication_method: :oauth2_token, auth_provider_id: oidc_provider.id) } describe "GET /scim_v2/ServiceProviderConfig" do - context "with the feature flag and enterprise enabled", with_ee: [:scim_api], with_flag: { scim_api: true } do + context "with enterprise feature enabled", with_ee: [:scim_api] do context "with static token" do let(:oauth_access_token) { create(:oauth_access_token, resource_owner: service_account, scopes: ["scim_v2"]) } let!(:token) { oauth_access_token.plaintext_token } @@ -157,23 +157,7 @@ RSpec.describe "SCIM API Authentication" do end end - context "with the feature flag disabled", with_ee: [:scim_api], with_flag: { scim_api: false } do - let(:oauth_access_token) { create(:oauth_access_token, resource_owner: service_account, scopes: ["scim_v2"]) } - let!(:token) { oauth_access_token.plaintext_token } - - it do - get "/scim_v2/ServiceProviderConfig", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - { "detail" => "Requires authentication", "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" } - ) - expect(last_response).to have_http_status(401) - end - end - - context "with the enterprise feature missing", with_flag: { scim_api: true } do + context "with the enterprise feature missing" do let(:oauth_access_token) { create(:oauth_access_token, resource_owner: service_account, scopes: ["scim_v2"]) } let!(:token) { oauth_access_token.plaintext_token } diff --git a/spec/requests/scim_v2/groups_spec.rb b/spec/requests/scim_v2/groups_spec.rb index dadcceddfb5..083a254af64 100644 --- a/spec/requests/scim_v2/groups_spec.rb +++ b/spec/requests/scim_v2/groups_spec.rb @@ -54,507 +54,412 @@ RSpec.describe "SCIM API Groups", with_ee: [:scim_api] do end describe "GET /scim_v2/Groups" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - before { group } + before { group } - it "responds with a list of groups excluding marked for deletion" do - create(:group_marked_for_deletion) + it "responds with a list of groups excluding marked for deletion" do + create(:group_marked_for_deletion) - get "/scim_v2/Groups", {}, headers + get "/scim_v2/Groups", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to match("Resources" => contain_exactly({ "displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }, { "displayName" => group_without_external_id.name, - "id" => group_without_external_id.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group_without_external_id.id}", - "created" => group_without_external_id.created_at.iso8601, - "lastModified" => group_without_external_id.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }), - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 2) - end - - it "filters results" do - filter = ERB::Util.url_encode("displayName Eq \"#{group.name}\"") - get "/scim_v2/Groups?filter=#{filter}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("Resources" => [{ "displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 1) - - filter = ERB::Util.url_encode('displayName Eq "NONEXISTENT GROUP NAME"') - get "/scim_v2/Groups?filter=#{filter}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("Resources" => [], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 0) - end + response_body = JSON.parse(last_response.body) + expect(response_body).to match("Resources" => contain_exactly({ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }, { "displayName" => group_without_external_id.name, + "id" => group_without_external_id.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group_without_external_id.id}", + "created" => group_without_external_id.created_at.iso8601, + "lastModified" => group_without_external_id.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }), + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 2) end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/Groups", {}, headers + it "filters results" do + filter = ERB::Util.url_encode("displayName Eq \"#{group.name}\"") + get "/scim_v2/Groups?filter=#{filter}", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [{ "displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] }], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 1) + + filter = ERB::Util.url_encode('displayName Eq "NONEXISTENT GROUP NAME"') + get "/scim_v2/Groups?filter=#{filter}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 0) end end describe "GET /scim_v2/Groups/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "responds with specific group data" do - group - get "/scim_v2/Groups/#{group.id}", {}, headers + it "responds with specific group data" do + group + get "/scim_v2/Groups/#{group.id}", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "excludes specified attributes" do - get "/scim_v2/Groups/#{group.id}?excludedAttributes=members", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - expect(response_body["members"]).to be_nil - end + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/Groups/#{group.id}", {}, headers + it "excludes specified attributes" do + get "/scim_v2/Groups/#{group.id}?excludedAttributes=members", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + expect(response_body["members"]).to be_nil end end describe "POST /scim_v2/Groups/" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - let(:group_name) { "Group 123" } + let(:group_name) { "Group 123" } - it "creates a group with members" do - user - request_body = { "displayName" => group_name, - "externalId" => external_group_id, - "members" => [{ "value" => user.id.to_s, - "$ref" => "/Users/#{user.id}" }], - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] } - expect do - post "/scim_v2/Groups/", request_body.to_json, headers - end.to change(Group, :count).by(1) + it "creates a group with members" do + user + request_body = { "displayName" => group_name, + "externalId" => external_group_id, + "members" => [{ "value" => user.id.to_s, + "$ref" => "/Users/#{user.id}" }], + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] } + expect do + post "/scim_v2/Groups/", request_body.to_json, headers + end.to change(Group, :count).by(1) - response_body = JSON.parse(last_response.body) - group = Group.find_by(name: group_name) - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "creates group without members specified" do - user - request_body = { "displayName" => "Group 123", - "externalId" => external_group_id, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] } - expect do - post "/scim_v2/Groups/", request_body.to_json, headers - end.to change(Group, :count).by(1) - - response_body = JSON.parse(last_response.body) - group = Group.find_by(name: group_name) - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end + response_body = JSON.parse(last_response.body) + group = Group.find_by(name: group_name) + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - post "/scim_v2/Groups/", "", headers + it "creates group without members specified" do + user + request_body = { "displayName" => "Group 123", + "externalId" => external_group_id, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"] } + expect do + post "/scim_v2/Groups/", request_body.to_json, headers + end.to change(Group, :count).by(1) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + group = Group.find_by(name: group_name) + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end end describe "DELETE /scim_v2/Groups/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "deletes specific group" do - group - delete "/scim_v2/Groups/#{group.id}", "", headers + it "deletes specific group" do + group + delete "/scim_v2/Groups/#{group.id}", "", headers - expect(last_response.body).to eq("") - expect(last_response).to have_http_status(204) + expect(last_response.body).to eq("") + expect(last_response).to have_http_status(204) - get "/scim_v2/Groups/#{group.id}", "", headers + get "/scim_v2/Groups/#{group.id}", "", headers - expect(last_response).to have_http_status(404) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Resource \"#{group.id}\" not found", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "404" - ) + expect(last_response).to have_http_status(404) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "detail" => "Resource \"#{group.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" + ) - perform_enqueued_jobs - assert_performed_jobs 1 + perform_enqueued_jobs + assert_performed_jobs 1 - get "/scim_v2/Groups/#{group.id}", "", headers + get "/scim_v2/Groups/#{group.id}", "", headers - expect(last_response).to have_http_status(404) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Resource \"#{group.id}\" not found", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "404" - ) - end - end - - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - delete "/scim_v2/Users/123", "", headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + expect(last_response).to have_http_status(404) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "detail" => "Resource \"#{group.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" + ) end end describe "PUT /scim_v2/Groups/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - let(:new_external_group_id) { "new_idp_group_id_123asdqwe12345" } + let(:new_external_group_id) { "new_idp_group_id_123asdqwe12345" } - before do - admin - group - end - - it "updates specific group by replacing it with newly provided data" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "active" => true, - "externalId" => new_external_group_id, - "displayName" => group.name, - "members" => [ - { "value" => user.id.to_s }, - { "value" => admin.id.to_s } - ] - } - - put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to match("displayName" => group.name, - "externalId" => new_external_group_id, - "id" => group.id.to_s, - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "members" => contain_exactly({ "value" => user.id.to_s }, { "value" => admin.id.to_s }), - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "updates members if there is $ref field present for every member(Keycloak plugin adds it for example)" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "externalId" => new_external_group_id, - "displayName" => group.name, - "members" => [ - { - "value" => user.id.to_s, - "$ref" => "/Users/#{user.id}" - }, - { - "value" => admin.id.to_s, - "$ref" => "/Users/#{admin.id}" - } - ] - } - - put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to match("displayName" => group.name, - "externalId" => new_external_group_id, - "id" => group.id.to_s, - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "members" => contain_exactly({ "value" => user.id.to_s }, { "value" => admin.id.to_s }), - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "updates members if there is no members field(Keycloak plugin sends memberless group request like that)" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], - "externalId" => new_external_group_id, - "displayName" => group.name - } - - put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to match("displayName" => group.name, - "externalId" => new_external_group_id, - "id" => group.id.to_s, - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "members" => [], - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end + before do + admin + group end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - put "/scim_v2/Groups/123", "", headers + it "updates specific group by replacing it with newly provided data" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "active" => true, + "externalId" => new_external_group_id, + "displayName" => group.name, + "members" => [ + { "value" => user.id.to_s }, + { "value" => admin.id.to_s } + ] + } - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to match("displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "members" => contain_exactly({ "value" => user.id.to_s }, { "value" => admin.id.to_s }), + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + end + + it "updates members if there is $ref field present for every member(Keycloak plugin adds it for example)" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId" => new_external_group_id, + "displayName" => group.name, + "members" => [ + { + "value" => user.id.to_s, + "$ref" => "/Users/#{user.id}" + }, + { + "value" => admin.id.to_s, + "$ref" => "/Users/#{admin.id}" + } + ] + } + + put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to match("displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "members" => contain_exactly({ "value" => user.id.to_s }, { "value" => admin.id.to_s }), + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + end + + it "updates members if there is no members field(Keycloak plugin sends memberless group request like that)" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"], + "externalId" => new_external_group_id, + "displayName" => group.name + } + + put "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to match("displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "members" => [], + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end end describe "PATCH /scim_v2/Groups/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "supports external_id replacing" do - group - new_external_group_id = "new_idp_user_id_123asdqwe12345" - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "replace", - "path" => "externalId", - "value" => new_external_group_id - }] - } - patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + it "supports external_id replacing" do + group + new_external_group_id = "new_idp_user_id_123asdqwe12345" + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "externalId", + "value" => new_external_group_id + }] + } + patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to eq("displayName" => group.name, - "externalId" => new_external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "supports replacing of members" do - group - user2 - - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "replace", - "path" => "members", - "value" => [{ - "value" => user2.id.to_s, - "$ref" => "/Users/#{user2.id}" - }] - }] - } - patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user2.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "supports adding of a member" do - group - user2 - - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "add", - "path" => "members", - "value" => [{ "value" => user2.id.to_s }] - }] - } - patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [{ "value" => user1.id.to_s }, - { "value" => user2.id.to_s }], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "supports removal of a member" do - group - - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "remove", - "path" => "members", - "value" => [{ "value" => user1.id.to_s }] - }] - } - patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "members" => [], - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end - - it "supports removal of a member with exclusion of members list from the response" do - group - - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "remove", - "path" => "members", - "value" => [{ "value" => user1.id.to_s }] - }] - } - patch "/scim_v2/Groups/#{group.id}?excludedAttributes=members", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - group.reload - expect(response_body).to eq("displayName" => group.name, - "externalId" => external_group_id, - "id" => group.id.to_s, - "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", - "created" => group.created_at.iso8601, - "lastModified" => group.updated_at.iso8601, - "resourceType" => "Group" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) - end + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq("displayName" => group.name, + "externalId" => new_external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - patch "/scim_v2/Groups/123", "", headers + it "supports replacing of members" do + group + user2 - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - end + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "members", + "value" => [{ + "value" => user2.id.to_s, + "$ref" => "/Users/#{user2.id}" + }] + }] + } + patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user2.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + end + + it "supports adding of a member" do + group + user2 + + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "add", + "path" => "members", + "value" => [{ "value" => user2.id.to_s }] + }] + } + patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [{ "value" => user1.id.to_s }, + { "value" => user2.id.to_s }], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + end + + it "supports removal of a member" do + group + + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "remove", + "path" => "members", + "value" => [{ "value" => user1.id.to_s }] + }] + } + patch "/scim_v2/Groups/#{group.id}", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "members" => [], + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) + end + + it "supports removal of a member with exclusion of members list from the response" do + group + + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "remove", + "path" => "members", + "value" => [{ "value" => user1.id.to_s }] + }] + } + patch "/scim_v2/Groups/#{group.id}?excludedAttributes=members", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + group.reload + expect(response_body).to eq("displayName" => group.name, + "externalId" => external_group_id, + "id" => group.id.to_s, + "meta" => { "location" => "http://test.host/scim_v2/Groups/#{group.id}", + "created" => group.created_at.iso8601, + "lastModified" => group.updated_at.iso8601, + "resourceType" => "Group" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:Group"]) end end end diff --git a/spec/requests/scim_v2/schemas_spec.rb b/spec/requests/scim_v2/schemas_spec.rb index 51aff9178b1..988f3746b41 100644 --- a/spec/requests/scim_v2/schemas_spec.rb +++ b/spec/requests/scim_v2/schemas_spec.rb @@ -41,240 +41,225 @@ RSpec.describe "SCIM API Schemas", with_ee: [:scim_api] do before { token } describe "GET /scim_v2/Schemas" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "responds with supported schemas" do - get "/scim_v2/Schemas", {}, headers + it "responds with supported schemas" do + get "/scim_v2/Schemas", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body["totalResults"]).to eq(2) - expect(response_body["schemas"]).to eq(["urn:ietf:params:scim:api:messages:2.0:ListResponse"]) - group_schema = response_body["Resources"].find { |r| r["name"] == "Group" } - user_schema = response_body["Resources"].find { |r| r["name"] == "User" } + response_body = JSON.parse(last_response.body) + expect(response_body["totalResults"]).to eq(2) + expect(response_body["schemas"]).to eq(["urn:ietf:params:scim:api:messages:2.0:ListResponse"]) + group_schema = response_body["Resources"].find { |r| r["name"] == "Group" } + user_schema = response_body["Resources"].find { |r| r["name"] == "User" } - expect(group_schema).to eq( - "name" => "Group", - "id" => "urn:ietf:params:scim:schemas:core:2.0:Group", - "description" => "Represents a Group", - "meta" => { "resourceType" => "Schema", - "location" => "http://test.host/scim_v2/Schemas?name=urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AGroup" }, - "attributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "displayName", - "type" => "string" }, - { "multiValued" => true, - "required" => false, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "type" => "complex", - "subAttributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "immutable", - "uniqueness" => "none", - "returned" => "default", - "name" => "value", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "immutable", - "uniqueness" => "none", - "returned" => "default", - "name" => "type", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "immutable", - "uniqueness" => "none", - "returned" => "default", - "name" => "display", - "type" => "string" } - ], - "name" => "members" }, - { "multiValued" => false, - "required" => true, - "caseExact" => true, - "mutability" => "readWrite", - "uniqueness" => "server", - "returned" => "default", - "name" => "externalId", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => true, - "mutability" => "readOnly", - "uniqueness" => "server", - "returned" => "default", - "name" => "id", - "type" => "string" } - ] - ) - expect(user_schema).to eq( - "name" => "User", - "id" => "urn:ietf:params:scim:schemas:core:2.0:User", - "description" => "Represents a User", - "meta" => { "resourceType" => "Schema", - "location" => "http://test.host/scim_v2/Schemas?name=urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AUser" }, - "attributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "server", - "returned" => "default", - "name" => "userName", - "type" => "string" }, - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "type" => "complex", - "subAttributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "familyName", - "type" => "string" }, - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "givenName", - "type" => "string" } - ], - "name" => "name" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "active", - "type" => "boolean" }, - { "multiValued" => true, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "type" => "complex", - "subAttributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "value", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readOnly", - "uniqueness" => "none", - "returned" => "default", - "name" => "display", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "type", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readWrite", - "uniqueness" => "none", - "returned" => "default", - "name" => "primary", - "type" => "boolean" } - ], - "name" => "emails" }, - { "multiValued" => true, - "required" => false, - "caseExact" => false, - "mutability" => "readOnly", - "uniqueness" => "none", - "returned" => "default", - "type" => "complex", - "subAttributes" => [ - { "multiValued" => false, - "required" => true, - "caseExact" => false, - "mutability" => "readOnly", - "uniqueness" => "none", - "returned" => "default", - "name" => "value", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readOnly", - "uniqueness" => "none", - "returned" => "default", - "name" => "display", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => false, - "mutability" => "readOnly", - "uniqueness" => "none", - "returned" => "default", - "name" => "type", - "type" => "string" } - ], - "name" => "groups" }, - { "multiValued" => false, - "required" => true, - "caseExact" => true, - "mutability" => "readWrite", - "uniqueness" => "server", - "returned" => "default", - "name" => "externalId", - "type" => "string" }, - { "multiValued" => false, - "required" => false, - "caseExact" => true, - "mutability" => "readOnly", - "uniqueness" => "server", - "returned" => "default", - "name" => "id", - "type" => "string" } - ] - ) - end - end - - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/Schemas", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - end + expect(group_schema).to eq( + "name" => "Group", + "id" => "urn:ietf:params:scim:schemas:core:2.0:Group", + "description" => "Represents a Group", + "meta" => { "resourceType" => "Schema", + "location" => "http://test.host/scim_v2/Schemas?name=urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AGroup" }, + "attributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "displayName", + "type" => "string" }, + { "multiValued" => true, + "required" => false, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "type" => "complex", + "subAttributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "immutable", + "uniqueness" => "none", + "returned" => "default", + "name" => "value", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "immutable", + "uniqueness" => "none", + "returned" => "default", + "name" => "type", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "immutable", + "uniqueness" => "none", + "returned" => "default", + "name" => "display", + "type" => "string" } + ], + "name" => "members" }, + { "multiValued" => false, + "required" => true, + "caseExact" => true, + "mutability" => "readWrite", + "uniqueness" => "server", + "returned" => "default", + "name" => "externalId", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => true, + "mutability" => "readOnly", + "uniqueness" => "server", + "returned" => "default", + "name" => "id", + "type" => "string" } + ] + ) + expect(user_schema).to eq( + "name" => "User", + "id" => "urn:ietf:params:scim:schemas:core:2.0:User", + "description" => "Represents a User", + "meta" => { "resourceType" => "Schema", + "location" => "http://test.host/scim_v2/Schemas?name=urn%3Aietf%3Aparams%3Ascim%3Aschemas%3Acore%3A2.0%3AUser" }, + "attributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "server", + "returned" => "default", + "name" => "userName", + "type" => "string" }, + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "type" => "complex", + "subAttributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "familyName", + "type" => "string" }, + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "givenName", + "type" => "string" } + ], + "name" => "name" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "active", + "type" => "boolean" }, + { "multiValued" => true, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "type" => "complex", + "subAttributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "value", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readOnly", + "uniqueness" => "none", + "returned" => "default", + "name" => "display", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "type", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readWrite", + "uniqueness" => "none", + "returned" => "default", + "name" => "primary", + "type" => "boolean" } + ], + "name" => "emails" }, + { "multiValued" => true, + "required" => false, + "caseExact" => false, + "mutability" => "readOnly", + "uniqueness" => "none", + "returned" => "default", + "type" => "complex", + "subAttributes" => [ + { "multiValued" => false, + "required" => true, + "caseExact" => false, + "mutability" => "readOnly", + "uniqueness" => "none", + "returned" => "default", + "name" => "value", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readOnly", + "uniqueness" => "none", + "returned" => "default", + "name" => "display", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => false, + "mutability" => "readOnly", + "uniqueness" => "none", + "returned" => "default", + "name" => "type", + "type" => "string" } + ], + "name" => "groups" }, + { "multiValued" => false, + "required" => true, + "caseExact" => true, + "mutability" => "readWrite", + "uniqueness" => "server", + "returned" => "default", + "name" => "externalId", + "type" => "string" }, + { "multiValued" => false, + "required" => false, + "caseExact" => true, + "mutability" => "readOnly", + "uniqueness" => "server", + "returned" => "default", + "name" => "id", + "type" => "string" } + ] + ) end end end diff --git a/spec/requests/scim_v2/service_provider_config_spec.rb b/spec/requests/scim_v2/service_provider_config_spec.rb index a7de242cf00..11288ea7abc 100644 --- a/spec/requests/scim_v2/service_provider_config_spec.rb +++ b/spec/requests/scim_v2/service_provider_config_spec.rb @@ -42,64 +42,48 @@ RSpec.describe "SCIM API ServiceProviderConfig" do describe "GET /scim_v2/ServiceProviderConfig" do context "with enterprise token supporting scim_api", with_ee: [:scim_api] do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "responds with full ServiceProviderConfig information if authorization is correct" do + it "responds with full ServiceProviderConfig information if authorization is correct" do + get "/scim_v2/ServiceProviderConfig", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to include("authenticationSchemes" => [{ "description" => "Bearer Token can be obtained in 3 different ways(https://www.openproject.org/docs/system-admin-guide/authentication/scim/#step-3-choose-an-authentication-method)", + + "name" => "OAuth Bearer Token", + "type" => "oauthbearertoken" }], + "bulk" => { "supported" => false }, + "changePassword" => { "supported" => false }, + "etag" => { "supported" => false }, + "filter" => { "maxResults" => 100, + "supported" => true }, + "patch" => { "supported" => true }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], + "sort" => { "supported" => false }) + end + + context "when authorization header contains an invalid token" do + let(:token) { object_double(Doorkeeper::AccessToken.new, plaintext_token: "123123") } + + it "responds with 401 Unauthorized" do get "/scim_v2/ServiceProviderConfig", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to include("authenticationSchemes" => [{ "description" => "Bearer Token can be obtained in 3 different ways(https://www.openproject.org/docs/system-admin-guide/authentication/scim/#step-3-choose-an-authentication-method)", - - "name" => "OAuth Bearer Token", - "type" => "oauthbearertoken" }], - "bulk" => { "supported" => false }, - "changePassword" => { "supported" => false }, - "etag" => { "supported" => false }, - "filter" => { "maxResults" => 100, - "supported" => true }, - "patch" => { "supported" => true }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], - "sort" => { "supported" => false }) - end - - context "when authorization header contains an invalid token" do - let(:token) { object_double(Doorkeeper::AccessToken.new, plaintext_token: "123123") } - - it "responds with 401 Unauthorized" do - get "/scim_v2/ServiceProviderConfig", {}, headers - - expect(last_response).to have_http_status(401) - expect(last_response.body).to eq("invalid_token") - end - end - - context "when there is no authorization header at all" do - let(:token) { object_double(Doorkeeper::AccessToken.new, plaintext_token: "123123") } - - it "responds with limited ServiceProviderConfig information" do - get "/scim_v2/ServiceProviderConfig", {}, { "CONTENT_TYPE" => "application/scim+json" } - - expect(last_response).to have_http_status(200) - response_body = JSON.parse(last_response.body) - expect(response_body.keys).to eq(["meta", "schemas", "authenticationSchemes"]) - expect(response_body).to include("authenticationSchemes" => [{ "description" => "Bearer Token can be obtained in 3 different ways(https://www.openproject.org/docs/system-admin-guide/authentication/scim/#step-3-choose-an-authentication-method)", - "name" => "OAuth Bearer Token", - "type" => "oauthbearertoken" }], - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]) - end + expect(last_response).to have_http_status(401) + expect(last_response.body).to eq("invalid_token") end end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/ServiceProviderConfig", {}, headers + context "when there is no authorization header at all" do + let(:token) { object_double(Doorkeeper::AccessToken.new, plaintext_token: "123123") } + it "responds with limited ServiceProviderConfig information" do + get "/scim_v2/ServiceProviderConfig", {}, { "CONTENT_TYPE" => "application/scim+json" } + + expect(last_response).to have_http_status(200) response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - { "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" } - ) - expect(last_response).to have_http_status(401) + expect(response_body.keys).to eq(["meta", "schemas", "authenticationSchemes"]) + expect(response_body).to include("authenticationSchemes" => [{ "description" => "Bearer Token can be obtained in 3 different ways(https://www.openproject.org/docs/system-admin-guide/authentication/scim/#step-3-choose-an-authentication-method)", + "name" => "OAuth Bearer Token", + "type" => "oauthbearertoken" }], + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"]) end end end diff --git a/spec/requests/scim_v2/users_spec.rb b/spec/requests/scim_v2/users_spec.rb index 558bf341231..dbf1df5f461 100644 --- a/spec/requests/scim_v2/users_spec.rb +++ b/spec/requests/scim_v2/users_spec.rb @@ -47,478 +47,414 @@ RSpec.describe "SCIM API Users", with_ee: [:scim_api] do before { token } describe "GET /scim_v2/Users" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - before do - admin - group - end - - it "responds with users list including locked(not_active users) and excluding users marked for deletion" do - user_marked_for_deletion = create(:user_marked_for_deletion) - locked_user = create(:locked_user) - - get "/scim_v2/Users", {}, headers - - response_body = JSON.parse(last_response.body) - ids = response_body["Resources"].map { |item| item["id"] } - expect(ids).to include(locked_user.id.to_s) - expect(response_body["Resources"].find { |resource| resource["id"] == locked_user.id.to_s }["active"]).to eq(false) - expect(ids).not_to include(user_marked_for_deletion.id.to_s) - expect(response_body).to match("Resources" => include({ "active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => admin.mail }], - "groups" => [], - "externalId" => external_admin_id, - "id" => admin.id.to_s, - "meta" => { "created" => admin.created_at.iso8601, - "lastModified" => admin.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{admin.id}", - "resourceType" => "User" }, - "name" => { "familyName" => admin.lastname, - "givenName" => admin.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => admin.login }, - { "active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => user.mail }], - "externalId" => external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login }), - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 5) - end - - it "filters results by familyName case-insensitively" do - expected_body = { "Resources" => [{ "active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => user.mail }], - "externalId" => external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login }], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 1 } - filter_with_existing_rows = ERB::Util.url_encode("familyName Eq \"#{user.lastname.upcase}\"") - get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq(expected_body) - - filter_with_existing_rows = ERB::Util.url_encode("familyName Eq \"#{user.lastname.downcase}\"") - get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq(expected_body) - - filter_with_nonexisting_rows = ERB::Util.url_encode('familyName Eq "NONEXISTENT USER LASTNAME"') - get "/scim_v2/Users?filter=#{filter_with_nonexisting_rows}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("Resources" => [], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 0) - end - - it "filters results by externalId case-sesitively" do - filter_with_existing_rows = ERB::Util.url_encode("externalId Eq \"#{external_user_id}\"") - get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("Resources" => [{ "active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => user.mail }], - "externalId" => external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login }], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 1) - - filter_with_nonexisting_rows = ERB::Util.url_encode("externalId Eq \"#{external_user_id.upcase}\"") - get "/scim_v2/Users?filter=#{filter_with_nonexisting_rows}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("Resources" => [], - "itemsPerPage" => 100, - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], - "startIndex" => 1, - "totalResults" => 0) - end + before do + admin + group end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/Users", {}, headers + it "responds with users list including locked(not_active users) and excluding users marked for deletion" do + user_marked_for_deletion = create(:user_marked_for_deletion) + locked_user = create(:locked_user) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + get "/scim_v2/Users", {}, headers + + response_body = JSON.parse(last_response.body) + ids = response_body["Resources"].map { |item| item["id"] } + expect(ids).to include(locked_user.id.to_s) + expect(response_body["Resources"].find { |resource| resource["id"] == locked_user.id.to_s }["active"]).to be(false) + expect(ids).not_to include(user_marked_for_deletion.id.to_s) + expect(response_body).to match("Resources" => include({ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => admin.mail }], + "groups" => [], + "externalId" => external_admin_id, + "id" => admin.id.to_s, + "meta" => { "created" => admin.created_at.iso8601, + "lastModified" => admin.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{admin.id}", + "resourceType" => "User" }, + "name" => { "familyName" => admin.lastname, + "givenName" => admin.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => admin.login }, + { "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }), + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 5) + end + + it "filters results by familyName case-insensitively" do + expected_body = { "Resources" => [{ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 1 } + filter_with_existing_rows = ERB::Util.url_encode("familyName Eq \"#{user.lastname.upcase}\"") + get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq(expected_body) + + filter_with_existing_rows = ERB::Util.url_encode("familyName Eq \"#{user.lastname.downcase}\"") + get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq(expected_body) + + filter_with_nonexisting_rows = ERB::Util.url_encode('familyName Eq "NONEXISTENT USER LASTNAME"') + get "/scim_v2/Users?filter=#{filter_with_nonexisting_rows}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 0) + end + + it "filters results by externalId case-sesitively" do + filter_with_existing_rows = ERB::Util.url_encode("externalId Eq \"#{external_user_id}\"") + get "/scim_v2/Users?filter=#{filter_with_existing_rows}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [{ "active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login }], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 1) + + filter_with_nonexisting_rows = ERB::Util.url_encode("externalId Eq \"#{external_user_id.upcase}\"") + get "/scim_v2/Users?filter=#{filter_with_nonexisting_rows}", {}, headers + + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("Resources" => [], + "itemsPerPage" => 100, + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:ListResponse"], + "startIndex" => 1, + "totalResults" => 0) end end describe "GET /scim_v2/Users/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "returns specific user data" do - group - get "/scim_v2/Users/#{user.id}", {}, headers + it "returns specific user data" do + group + get "/scim_v2/Users/#{user.id}", {}, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => user.mail }], - "externalId" => external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login) - end - end - - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - get "/scim_v2/Users/#{user.id}", {}, headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login) end end describe "POST /scim_v2/Users/" do before { oidc_provider } - context "with the feature flag enabled", with_flag: { scim_api: true } do - context "when user with userName has already exists" do - it "responds with uniqueness error" do - group - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => external_user_id, - "userName" => user.login, - "name" => { - "givenName" => "John", - "familyName" => "Doe" - }, - "active" => true, - "emails" => [{ - "value" => "jdoe@example.com", - "type" => "work", - "primary" => true - }] - } - - post "/scim_v2/Users/", request_body.to_json, headers - - expect(last_response).to have_http_status(409) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail" => "Operation failed due to a uniqueness constraint: Username has already been taken.", - "status" => "409", - "scimType" => "uniqueness" - ) - end - end - - it "responds with 400 when email is missing" do + context "when user with userName has already exists" do + it "responds with uniqueness error" do group request_body = { "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], "externalId" => external_user_id, - "userName" => "NewUserName", + "userName" => user.login, "name" => { "givenName" => "John", "familyName" => "Doe" }, "active" => true, - "emails" => [] + "emails" => [{ + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + }] } post "/scim_v2/Users/", request_body.to_json, headers - expect(last_response).to have_http_status(400) + expect(last_response).to have_http_status(409) response_body = JSON.parse(last_response.body) expect(response_body).to eq( "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail" => "Invalid resource: Emails is required.", - "status" => "400", - "scimType" => "invalidValue" + "detail" => "Operation failed due to a uniqueness constraint: Username has already been taken.", + "status" => "409", + "scimType" => "uniqueness" ) end - - it "responds with 400 when familyName is missing" do - group - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => external_user_id, - "userName" => "NewUserName", - "name" => { - "givenName" => "John" - }, - "active" => true, - "emails" => [ - { - "value" => "jdoe@example.com", - "type" => "work", - "primary" => true - } - ] - } - - post "/scim_v2/Users/", request_body.to_json, headers - - expect(last_response).to have_http_status(400) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail" => "Invalid resource: Name familyname is required.", - "status" => "400", - "scimType" => "invalidValue" - ) - end - - it "creates user with provided data and excludes some attributes" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => external_user_id, - "userName" => "jdoe", - "name" => { - "givenName" => "John", - "familyName" => "Doe" - }, - "active" => true, - "emails" => [ - { - "value" => "jdoe@example.com", - "type" => "work", - "primary" => true - } - ] - } - post "/scim_v2/Users/?excludedAttributes=emails,name.givenName", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - created_user = User.find_by(login: "jdoe") - expect(created_user).to be_present - expect(response_body).to eq("active" => true, - "externalId" => external_user_id, - "groups" => [], - "id" => created_user.id.to_s, - "meta" => { "created" => created_user.created_at.iso8601, - "lastModified" => created_user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{created_user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => "Doe" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => "jdoe") - end - - it "creates user with provided data" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => external_user_id, - "userName" => "jdoe", - "name" => { - "givenName" => "John", - "familyName" => "Doe" - }, - "active" => true, - "emails" => [ - { - "value" => "jdoe@example.com", - "type" => "work", - "primary" => true - } - ] - } - post "/scim_v2/Users/", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - created_user = User.find_by(login: "jdoe") - expect(created_user).to be_present - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => "jdoe@example.com" }], - "externalId" => external_user_id, - "groups" => [], - "id" => created_user.id.to_s, - "meta" => { "created" => created_user.created_at.iso8601, - "lastModified" => created_user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{created_user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => "Doe", - "givenName" => "John" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => "jdoe") - end - - it "creates user with any email type string provided" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => external_user_id, - "userName" => "jdoe", - "name" => { - "givenName" => "John", - "familyName" => "Doe" - }, - "active" => true, - "emails" => [ - { - "value" => "jdoe@example.com", - "type" => "untyped" - } - ] - } - post "/scim_v2/Users/", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - created_user = User.find_by(login: "jdoe") - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => "jdoe@example.com" }], - "externalId" => external_user_id, - "groups" => [], - "id" => created_user.id.to_s, - "meta" => { "created" => created_user.created_at.iso8601, - "lastModified" => created_user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{created_user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => "Doe", - "givenName" => "John" }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => "jdoe") - end end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - post "/scim_v2/Users/", "", headers + it "responds with 400 when email is missing" do + group + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "NewUserName", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [] + } - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + post "/scim_v2/Users/", request_body.to_json, headers + + expect(last_response).to have_http_status(400) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "detail" => "Invalid resource: Emails is required.", + "status" => "400", + "scimType" => "invalidValue" + ) + end + + it "responds with 400 when familyName is missing" do + group + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "NewUserName", + "name" => { + "givenName" => "John" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } + + post "/scim_v2/Users/", request_body.to_json, headers + + expect(last_response).to have_http_status(400) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "detail" => "Invalid resource: Name familyname is required.", + "status" => "400", + "scimType" => "invalidValue" + ) + end + + it "creates user with provided data and excludes some attributes" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } + post "/scim_v2/Users/?excludedAttributes=emails,name.givenName", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + created_user = User.find_by(login: "jdoe") + expect(created_user).to be_present + expect(response_body).to eq("active" => true, + "externalId" => external_user_id, + "groups" => [], + "id" => created_user.id.to_s, + "meta" => { "created" => created_user.created_at.iso8601, + "lastModified" => created_user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{created_user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => "Doe" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => "jdoe") + end + + it "creates user with provided data" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } + post "/scim_v2/Users/", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + created_user = User.find_by(login: "jdoe") + expect(created_user).to be_present + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => "jdoe@example.com" }], + "externalId" => external_user_id, + "groups" => [], + "id" => created_user.id.to_s, + "meta" => { "created" => created_user.created_at.iso8601, + "lastModified" => created_user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{created_user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => "Doe", + "givenName" => "John" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => "jdoe") + end + + it "creates user with any email type string provided" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "untyped" + } + ] + } + post "/scim_v2/Users/", request_body.to_json, headers + + response_body = JSON.parse(last_response.body) + created_user = User.find_by(login: "jdoe") + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => "jdoe@example.com" }], + "externalId" => external_user_id, + "groups" => [], + "id" => created_user.id.to_s, + "meta" => { "created" => created_user.created_at.iso8601, + "lastModified" => created_user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{created_user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => "Doe", + "givenName" => "John" }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => "jdoe") end end describe "DELETE /scim_v2/Users/:id" do - context "with the feature flag enabled", with_flag: { scim_api: true } do - context "when users_deletable_by_admins is enabled", with_settings: { users_deletable_by_admins: true } do - it do - group + context "when users_deletable_by_admins is enabled", with_settings: { users_deletable_by_admins: true } do + it do + group - delete "/scim_v2/Users/#{user.id}", "", headers + delete "/scim_v2/Users/#{user.id}", "", headers - expect(last_response.body).to eq("") - expect(last_response).to have_http_status(204) + expect(last_response.body).to eq("") + expect(last_response).to have_http_status(204) - get "/scim_v2/Users/#{user.id}", "", headers + get "/scim_v2/Users/#{user.id}", "", headers - expect(last_response).to have_http_status(404) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Resource \"#{user.id}\" not found", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "404" - ) + expect(last_response).to have_http_status(404) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "detail" => "Resource \"#{user.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" + ) - perform_enqueued_jobs - assert_performed_jobs 1 + perform_enqueued_jobs + assert_performed_jobs 1 - get "/scim_v2/Users/#{user.id}", "", headers + get "/scim_v2/Users/#{user.id}", "", headers - expect(last_response).to have_http_status(404) - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Resource \"#{user.id}\" not found", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "404" - ) - end - end - - context "when users_deletable_by_admins is disabled", with_settings: { users_deletable_by_admins: false } do - it "responds with 403 error" do - group - delete "/scim_v2/Users/#{user.id}", "", headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq("schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "detail" => "Action forbidden: insufficient permissions.", - "status" => "403") - expect(last_response).to have_http_status(403) - end + expect(last_response).to have_http_status(404) + response_body = JSON.parse(last_response.body) + expect(response_body).to eq( + "detail" => "Resource \"#{user.id}\" not found", + "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "status" => "404" + ) end end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - delete "/scim_v2/Users/123", "", headers + context "when users_deletable_by_admins is disabled", with_settings: { users_deletable_by_admins: false } do + it "responds with 403 error" do + group + delete "/scim_v2/Users/#{user.id}", "", headers response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) + expect(response_body).to eq("schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], + "detail" => "Action forbidden: insufficient permissions.", + "status" => "403") + expect(last_response).to have_http_status(403) end end end @@ -526,62 +462,46 @@ RSpec.describe "SCIM API Users", with_ee: [:scim_api] do describe "PUT /scim_v2/Users/:id" do before { group } - context "with the feature flag enabled", with_flag: { scim_api: true } do - let(:new_external_user_id) { "new_idp_user_id_123asdqwe12345" } + let(:new_external_user_id) { "new_idp_user_id_123asdqwe12345" } - it "updates existing user by replacing with newly provided data" do - request_body = { - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "externalId" => new_external_user_id, - "userName" => "jdoe", - "name" => { - "givenName" => "John", - "familyName" => "Doe" - }, - "active" => true, - "emails" => [ - { - "value" => "jdoe@example.com", - "type" => "work", - "primary" => true - } - ] - } + it "updates existing user by replacing with newly provided data" do + request_body = { + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "externalId" => new_external_user_id, + "userName" => "jdoe", + "name" => { + "givenName" => "John", + "familyName" => "Doe" + }, + "active" => true, + "emails" => [ + { + "value" => "jdoe@example.com", + "type" => "work", + "primary" => true + } + ] + } - put "/scim_v2/Users/#{user.id}", request_body.to_json, headers + put "/scim_v2/Users/#{user.id}", request_body.to_json, headers - response_body = JSON.parse(last_response.body) - user.reload - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => request_body["emails"].first["value"] }], - "externalId" => new_external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => request_body["name"]["familyName"], - "givenName" => request_body["name"]["givenName"] }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => request_body["userName"]) - end - end - - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - put "/scim_v2/Users/123", "", headers - - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => request_body["emails"].first["value"] }], + "externalId" => new_external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => request_body["name"]["familyName"], + "givenName" => request_body["name"]["givenName"] }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => request_body["userName"]) end end @@ -590,88 +510,72 @@ RSpec.describe "SCIM API Users", with_ee: [:scim_api] do before { group } - context "with the feature flag enabled", with_flag: { scim_api: true } do - it "changes external_id" do - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "replace", - "path" => "externalId", - "value" => new_external_user_id - }] - } - patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers + it "changes external_id" do + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "externalId", + "value" => new_external_user_id + }] + } + patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers - response_body = JSON.parse(last_response.body) - user.reload - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => user.mail }], - "externalId" => new_external_user_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login) - end - - it "changes email value" do - new_email_value = "qwertty@gmail.com" - request_body = { - "schemas" => - ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], - "Operations" => [{ - "op" => "replace", - "path" => "emails[type eq \"work\"]", - "value" => - { - "type" => "work", - "value" => new_email_value, - "primary" => true - } - }] - } - patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers - - response_body = JSON.parse(last_response.body) - user.reload - expect(response_body).to eq("active" => true, - "emails" => [{ "primary" => true, - "type" => "work", - "value" => new_email_value }], - "externalId" => user.scim_external_id, - "groups" => [{ "value" => group.id.to_s }], - "id" => user.id.to_s, - "meta" => { "created" => user.created_at.iso8601, - "lastModified" => user.updated_at.iso8601, - "location" => "http://test.host/scim_v2/Users/#{user.id}", - "resourceType" => "User" }, - "name" => { "familyName" => user.lastname, - "givenName" => user.firstname }, - "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], - "userName" => user.login) - end + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => user.mail }], + "externalId" => new_external_user_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login) end - context "with the feature flag disabled", with_flag: { scim_api: false } do - it do - patch "/scim_v2/Users/123", "", headers + it "changes email value" do + new_email_value = "qwertty@gmail.com" + request_body = { + "schemas" => + ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + "Operations" => [{ + "op" => "replace", + "path" => "emails[type eq \"work\"]", + "value" => + { + "type" => "work", + "value" => new_email_value, + "primary" => true + } + }] + } + patch "/scim_v2/Users/#{user.id}", request_body.to_json, headers - response_body = JSON.parse(last_response.body) - expect(response_body).to eq( - "detail" => "Requires authentication", - "schemas" => ["urn:ietf:params:scim:api:messages:2.0:Error"], - "status" => "401" - ) - expect(last_response).to have_http_status(401) - end + response_body = JSON.parse(last_response.body) + user.reload + expect(response_body).to eq("active" => true, + "emails" => [{ "primary" => true, + "type" => "work", + "value" => new_email_value }], + "externalId" => user.scim_external_id, + "groups" => [{ "value" => group.id.to_s }], + "id" => user.id.to_s, + "meta" => { "created" => user.created_at.iso8601, + "lastModified" => user.updated_at.iso8601, + "location" => "http://test.host/scim_v2/Users/#{user.id}", + "resourceType" => "User" }, + "name" => { "familyName" => user.lastname, + "givenName" => user.firstname }, + "schemas" => ["urn:ietf:params:scim:schemas:core:2.0:User"], + "userName" => user.login) end end end