fleet/ee/server/integrationtest/scim/scim_test.go
Magnus Jensen 4e56d53ccd
Require given and fullname from SCIM (#33336)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** Followup fix for #30888

See
https://github.com/fleetdm/fleet/issues/30888#issuecomment-3321700108

Needs to be cherry-picked into 4.74

# Checklist for submitter

If some of the following don't apply, delete the relevant line.


## Testing

- [x] Added/updated automated tests
- [x] QA'd all new/changed functionality manually
2025-09-23 17:09:42 +03:00

4511 lines
160 KiB
Go

package scim
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/elimity-com/scim/errors"
"github.com/fleetdm/fleet/v4/server/authz"
"github.com/fleetdm/fleet/v4/server/datastore/mysql"
"github.com/fleetdm/fleet/v4/server/service"
"github.com/fleetdm/fleet/v4/server/service/contract"
"github.com/fleetdm/fleet/v4/server/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSCIM(t *testing.T) {
s := SetUpSuite(t, "integrationtest.SCIM")
cases := []struct {
name string
fn func(t *testing.T, s *Suite)
}{
{"Auth", testAuth},
{"BaseEndpoints", testBaseEndpoints},
{"Users", testUsersBasicCRUD},
{"Groups", testGroupsBasicCRUD},
{"CreateUser", testCreateUser},
{"CreateGroup", testCreateGroup},
{"UpdateUser", testUpdateUser},
{"UpdateGroup", testUpdateGroup},
{"PatchUserEmails", testPatchUserEmails},
{"PatchUserAttributes", testPatchUserAttributes},
{"PatchGroupAttributes", testPatchGroupAttributes},
{"PatchGroupMembers", testPatchGroupMembers},
{"UsersPagination", testUsersPagination},
{"GroupsPagination", testGroupsPagination},
{"UsersAndGroups", testUsersAndGroups},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
defer mysql.TruncateTables(t, s.DS, []string{
"host_scim_user", "scim_users", "scim_user_emails", "scim_groups",
"scim_user_group", "scim_last_request",
}...)
c.fn(t, s)
})
}
}
func testAuth(t *testing.T, s *Suite) {
t.Cleanup(func() {
s.Token = s.GetTestAdminToken(t)
})
// Unauthenticated
s.Token = "bozo"
var resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusUnauthorized, &resp)
assert.Contains(t, resp["detail"], "Authentication")
assert.EqualValues(t, resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusUnauthorized, &scimDetails)
// Make sure unauthenticated response wasn't saved as the last SCIM request
s.Token = s.GetTestToken(t, service.TestMaintainerUserEmail, test.GoodPassword)
scimDetails = contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
assert.Nil(t, scimDetails.LastRequest, "last_request should NOT be present for unauthenticated requests")
// Unauthorized
resp = nil
s.Token = s.GetTestToken(t, service.TestObserverUserEmail, test.GoodPassword)
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusForbidden, &resp)
assert.Contains(t, resp["detail"], "forbidden")
assert.EqualValues(t, resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusForbidden, &scimDetails)
// Make sure unauthorized response WAS saved as the last SCIM request
s.Token = s.GetTestToken(t, service.TestMaintainerUserEmail, test.GoodPassword)
scimDetails = contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "error", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Equal(t, authz.ForbiddenErrorMessage, scimDetails.LastRequest.Details)
// Authorized
resp = nil
s.Token = s.GetTestToken(t, service.TestMaintainerUserEmail, test.GoodPassword)
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusOK, &resp)
assert.EqualValues(t, resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
}
func testBaseEndpoints(t *testing.T, s *Suite) {
// Make sure SCIM details.last_request DOES NOT exist
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
assert.Nil(t, scimDetails.LastRequest)
t.Run("Test /Schemas endpoint", func(t *testing.T) {
var schemasResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Schemas"), nil, http.StatusOK, &schemasResp)
// Verify last request was recorded
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "success", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Empty(t, scimDetails.LastRequest.Details)
// Verify schemas response
assert.EqualValues(t, schemasResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
resources, ok := schemasResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.GreaterOrEqual(t, len(resources), 2, "Should have at least 2 schemas (User and Group)")
// Check for User and Group schemas
foundUser := false
foundGroup := false
for _, resource := range resources {
schema, ok := resource.(map[string]interface{})
assert.True(t, ok, "Schema should be an object")
id, ok := schema["id"].(string)
assert.True(t, ok, "Schema ID should be a string")
if id == "urn:ietf:params:scim:schemas:core:2.0:User" {
foundUser = true
attributes := schema["attributes"].([]interface{})
assert.NotNil(t, attributes, "User schema should have attributes")
} else if id == "urn:ietf:params:scim:schemas:core:2.0:Group" {
foundGroup = true
attributes := schema["attributes"].([]interface{})
assert.NotNil(t, attributes, "Group schema should have attributes")
}
}
assert.True(t, foundUser, "User schema should be present")
assert.True(t, foundGroup, "Group schema should be present")
})
t.Run("Test /ServiceProviderConfig endpoint", func(t *testing.T) {
var configResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/ServiceProviderConfig"), nil, http.StatusOK, &configResp)
// Verify service provider config response
assert.EqualValues(t, configResp["schemas"], []interface{}{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"})
assert.NotNil(t, configResp["documentationUri"])
})
t.Run("Test /ResourceTypes endpoint", func(t *testing.T) {
var resourceTypesResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/ResourceTypes"), nil, http.StatusOK, &resourceTypesResp)
// Verify resource types response
assert.EqualValues(t, resourceTypesResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
resourceTypes, ok := resourceTypesResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.GreaterOrEqual(t, len(resourceTypes), 2, "Should have at least 2 resource types (User and Group)")
// Check for User and Group resource types
foundUserResource := false
foundGroupResource := false
for _, resource := range resourceTypes {
resourceType, ok := resource.(map[string]interface{})
assert.True(t, ok, "Resource type should be an object")
name, ok := resourceType["name"].(string)
assert.True(t, ok, "Resource type name should be a string")
if name == "User" {
foundUserResource = true
assert.Equal(t, "/Users", resourceType["endpoint"])
assert.Equal(t, "urn:ietf:params:scim:schemas:core:2.0:User", resourceType["schema"])
} else if name == "Group" {
foundGroupResource = true
assert.Equal(t, "/Groups", resourceType["endpoint"])
assert.Equal(t, "urn:ietf:params:scim:schemas:core:2.0:Group", resourceType["schema"])
}
}
assert.True(t, foundUserResource, "User resource type should be present")
assert.True(t, foundGroupResource, "Group resource type should be present")
})
}
// createTestUser creates a test user with the given username and returns the user ID and response
func createTestUser(t *testing.T, s *Suite, userName string) (string, map[string]interface{}) {
createUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": userName,
"name": map[string]interface{}{
"givenName": "Test",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": userName,
"type": "work",
"primary": true,
},
},
"active": true,
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createResp)
// Verify the created user
assert.Equal(t, userName, createResp["userName"])
assert.Equal(t, true, createResp["active"])
// Extract the user ID
userID := createResp["id"].(string)
assert.NotEmpty(t, userID)
return userID, createResp
}
func testUsersBasicCRUD(t *testing.T, s *Suite) {
// Test creating a user
userName := "testuser@example.com"
userID, _ := createTestUser(t, s, userName)
// Test getting a user by ID
var getResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/"+userID), nil, http.StatusOK, &getResp)
assert.Equal(t, userID, getResp["id"])
assert.Equal(t, userName, getResp["userName"])
assert.Equal(t, true, getResp["active"])
// Test getting a user with a bad ID
var errResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/99999"), nil, http.StatusNotFound, &errResp)
assert.Contains(t, errResp["detail"], "Resource 99999 not found")
assert.EqualValues(t, errResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
// Make sure the error is reflected in the last request
scimDetails := contract.ScimDetailsResponse{}
s.DoJSON(t, "GET", scimPath("/details"), nil, http.StatusOK, &scimDetails)
require.NotNil(t, scimDetails.LastRequest)
assert.Equal(t, "error", scimDetails.LastRequest.Status)
assert.NotZero(t, scimDetails.LastRequest.RequestedAt)
assert.Equal(t, errResp["detail"], scimDetails.LastRequest.Details)
// Test listing users
var listResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &listResp)
assert.EqualValues(t, listResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
resources, ok := listResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, len(resources), 1, "Should have 1 user")
// Test filtering users by userName
var filterResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &filterResp, "filter", `userName eq "testuser@example.com"`)
assert.EqualValues(t, filterResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
filterResources, ok := filterResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 1, len(filterResources), "Should have exactly 1 user matching the filter")
// Test filtering users by userName with random capitalization (case insensitivity)
randomCapUserName := "TeStUsEr@ExAmPlE.cOm" // Randomly capitalized version of testuser@example.com
var caseInsensitiveResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &caseInsensitiveResp, "filter", `userName eq "`+randomCapUserName+`"`)
assert.EqualValues(t, caseInsensitiveResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
caseInsensitiveResources, ok := caseInsensitiveResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 1, len(caseInsensitiveResources), "Should have exactly 1 user matching the case-insensitive filter")
// Verify it's the same user
if len(caseInsensitiveResources) > 0 {
user, ok := caseInsensitiveResources[0].(map[string]interface{})
assert.True(t, ok, "User should be an object")
assert.Equal(t, userID, user["id"], "Should be the same user despite case differences in userName filter")
assert.Equal(t, userName, user["userName"], "Original userName should be preserved")
}
// Test filtering users by non-existent userName
filterResp = nil
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &filterResp, "filter", `userName eq "bozo@example.com"`)
assert.EqualValues(t, filterResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
filterResources, ok = filterResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Empty(t, filterResources, "Should have no users matching the filter")
// Test updating a user
updateUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": userName,
"name": map[string]interface{}{
"givenName": "Updated",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": userName,
"type": "work",
"primary": true,
},
},
"active": true,
}
var updateResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+userID), updateUserPayload, http.StatusOK, &updateResp)
assert.Equal(t, userName, updateResp["userName"])
// Verify the name was updated
name, ok := updateResp["name"].(map[string]interface{})
assert.True(t, ok, "Name should be an object")
assert.Equal(t, "Updated", name["givenName"])
// Test patching a user (updating just the active status)
patchUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "Replace",
"path": "active",
"value": false,
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchUserPayload, http.StatusOK, &patchResp)
assert.Equal(t, false, patchResp["active"])
// Test patching a user without path attribute (updating just the active status)
patchUserPayload = map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"active": true,
},
},
},
}
patchResp = nil
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchUserPayload, http.StatusOK, &patchResp)
assert.Equal(t, true, patchResp["active"])
// Test deleting a user
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
// Verify the user was deleted by trying to get it (should return 404)
var errorResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/"+userID), nil, http.StatusNotFound, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], "not found")
// Test replacing a user that doesn't exist
nonExistentUserID := "99999"
updateNonExistentUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "nonexistent@example.com",
"name": map[string]interface{}{
"givenName": "Non",
"familyName": "Existent",
},
"emails": []map[string]interface{}{
{
"value": "nonexistent@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var updateNonExistentResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+nonExistentUserID), updateNonExistentUserPayload, http.StatusNotFound, &updateNonExistentResp)
assert.EqualValues(t, updateNonExistentResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, updateNonExistentResp["detail"], "not found")
// Test deleting a user that was already deleted
var deleteAgainResp map[string]interface{}
s.DoJSON(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNotFound, &deleteAgainResp)
assert.EqualValues(t, deleteAgainResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, deleteAgainResp["detail"], "not found")
}
// createTestGroup creates a test group with the given display name and members and returns the group ID and response
func createTestGroup(t *testing.T, s *Suite, displayName string, memberIDs []string) (string, map[string]interface{}) {
members := make([]map[string]interface{}, 0, len(memberIDs))
for _, id := range memberIDs {
members = append(members, map[string]interface{}{
"value": id,
})
}
createGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": displayName,
}
if len(members) > 0 {
createGroupPayload["members"] = members
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), createGroupPayload, http.StatusCreated, &createResp)
// Verify the created group
assert.Equal(t, displayName, createResp["displayName"])
// Extract the group ID
groupID := createResp["id"].(string)
assert.NotEmpty(t, groupID)
return groupID, createResp
}
func testGroupsBasicCRUD(t *testing.T, s *Suite) {
// First, create a user to add as a member of the group
userID, _ := createTestUser(t, s, "groupmember@example.com")
// Test creating a group
groupID, createResp := createTestGroup(t, s, "Test Group", []string{userID})
// Verify members
members, ok := createResp["members"].([]interface{})
assert.True(t, ok, "Members should be an array")
require.Equal(t, 1, len(members), "Should have 1 member")
member := members[0].(map[string]interface{})
assert.Equal(t, userID, member["value"])
assert.Equal(t, "User", member["type"])
// Test getting a group by ID
var getResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups/"+groupID), nil, http.StatusOK, &getResp)
assert.Equal(t, groupID, getResp["id"])
assert.Equal(t, "Test Group", getResp["displayName"])
// Verify members in the GET response
getMembers, ok := getResp["members"].([]interface{})
assert.True(t, ok, "Members should be an array")
assert.Equal(t, 1, len(getMembers), "Should have 1 member")
getMember := getMembers[0].(map[string]interface{})
assert.Equal(t, userID, getMember["value"])
// Test getting a group by ID with excludedAttributes=members
var getExcludedResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups/"+groupID), nil, http.StatusOK, &getExcludedResp, "excludedAttributes", "members")
assert.Equal(t, groupID, getExcludedResp["id"])
assert.Equal(t, "Test Group", getExcludedResp["displayName"])
// Verify members are not included in the response
membersExist := getExcludedResp["members"] != nil
assert.False(t, membersExist, "Members should not be included when excludedAttributes=members")
// Test getting a group with a bad ID
var errResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups/99999"), nil, http.StatusNotFound, &errResp)
assert.Contains(t, errResp["detail"], "Resource 99999 not found")
assert.EqualValues(t, errResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
// Test listing groups
var listResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &listResp)
assert.EqualValues(t, listResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
resources, ok := listResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.GreaterOrEqual(t, len(resources), 1, "Should have at least 1 group")
// Test listing groups with excludedAttributes=members
var listExcludedResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &listExcludedResp, "excludedAttributes", "members")
assert.EqualValues(t, listExcludedResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
resourcesExcluded, ok := listExcludedResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.GreaterOrEqual(t, len(resourcesExcluded), 1, "Should have at least 1 group")
// Verify members are not included in any of the groups
for _, resource := range resourcesExcluded {
group, ok := resource.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
_, hasMembersField := group["members"]
assert.False(t, hasMembersField, "Group should not have members field when excludedAttributes=members")
}
// Create a second group with a different display name for filtering tests
secondGroupID, _ := createTestGroup(t, s, "Second Test Group", []string{userID})
// Test filtering groups by displayName - first group
var filterResp1 map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &filterResp1, "filter", `displayName eq "Test Group"`)
assert.EqualValues(t, filterResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
filterResources1, ok := filterResp1["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 1, len(filterResources1), "Should have exactly 1 group matching the filter")
// Verify it's the correct group
if len(filterResources1) > 0 {
group, ok := filterResources1[0].(map[string]interface{})
assert.True(t, ok, "Group should be an object")
assert.Equal(t, groupID, group["id"], "Should be the first group")
assert.Equal(t, "Test Group", group["displayName"], "Should have the correct display name")
}
// Test filtering groups by displayName - second group
var filterResp2 map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &filterResp2, "filter", `displayName eq "Second Test Group"`)
assert.EqualValues(t, filterResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
filterResources2, ok := filterResp2["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 1, len(filterResources2), "Should have exactly 1 group matching the filter")
// Verify it's the correct group
if len(filterResources2) > 0 {
group, ok := filterResources2[0].(map[string]interface{})
assert.True(t, ok, "Group should be an object")
assert.Equal(t, secondGroupID, group["id"], "Should be the second group")
assert.Equal(t, "Second Test Group", group["displayName"], "Should have the correct display name")
}
// Test filtering groups by non-existent displayName
var filterResp3 map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &filterResp3, "filter", `displayName eq "Non-Existent Group"`)
assert.EqualValues(t, filterResp3["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
filterResources3, ok := filterResp3["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Empty(t, filterResources3, "Should have no groups matching the filter")
// Test updating a group
updateGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Updated Test Group",
"members": []map[string]interface{}{}, // Remove all members
}
var updateResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+groupID), updateGroupPayload, http.StatusOK, &updateResp)
assert.Equal(t, "Updated Test Group", updateResp["displayName"])
// Verify members were removed
updateMembersExist := updateResp["members"] != nil
assert.False(t, updateMembersExist, "Members should not be present or should be empty")
assert.False(t, membersExist, "Members should not be present or should be empty")
// Test deleting a group
s.Do(t, "DELETE", scimPath("/Groups/"+groupID), nil, http.StatusNoContent)
// Verify the group was deleted by trying to get it (should return 404)
var errorResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups/"+groupID), nil, http.StatusNotFound, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], "not found")
// Test replacing a group that doesn't exist
nonExistentGroupID := "99999"
updateNonExistentGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Non-Existent Group",
"members": []map[string]interface{}{},
}
var updateNonExistentResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+nonExistentGroupID), updateNonExistentGroupPayload, http.StatusNotFound, &updateNonExistentResp)
assert.EqualValues(t, updateNonExistentResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, updateNonExistentResp["detail"], "not found")
// Test deleting a group that was already deleted
var deleteAgainResp map[string]interface{}
s.DoJSON(t, "DELETE", scimPath("/Groups/"+groupID), nil, http.StatusNotFound, &deleteAgainResp)
assert.EqualValues(t, deleteAgainResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, deleteAgainResp["detail"], "not found")
// Delete the user we created
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
func testCreateGroup(t *testing.T, s *Suite) {
// Create multiple test users to be added as members
userIDs := make([]string, 0, 5)
for i := 1; i <= 5; i++ {
userName := fmt.Sprintf("group-test-user-%d@example.com", i)
createUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": userName,
"name": map[string]interface{}{
"givenName": fmt.Sprintf("User%d", i),
"familyName": "GroupTest",
},
"emails": []map[string]interface{}{
{
"value": userName,
"type": "work",
"primary": true,
},
},
"active": true,
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createResp)
userID := createResp["id"].(string)
userIDs = append(userIDs, userID)
}
// Test 1: Create a group with 0 members
emptyGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Empty Group",
}
var emptyGroupResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), emptyGroupPayload, http.StatusCreated, &emptyGroupResp)
// Verify the created group
assert.Equal(t, "Empty Group", emptyGroupResp["displayName"])
// Verify no members
_, membersExist := emptyGroupResp["members"]
assert.False(t, membersExist, "Members should not be present for an empty group")
emptyGroupID := emptyGroupResp["id"].(string)
assert.NotEmpty(t, emptyGroupID)
// Test 2: Create a group with many members
members := make([]map[string]interface{}, 0, len(userIDs))
for _, userID := range userIDs {
members = append(members, map[string]interface{}{
"value": userID,
})
}
manyMembersGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Many Members Group",
"members": members,
}
var manyMembersGroupResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), manyMembersGroupPayload, http.StatusCreated, &manyMembersGroupResp)
// Verify the created group
assert.Equal(t, "Many Members Group", manyMembersGroupResp["displayName"])
// Verify members
respMembers, ok := manyMembersGroupResp["members"].([]interface{})
assert.True(t, ok, "Members should be an array")
assert.Equal(t, len(userIDs), len(respMembers), "Should have the same number of members as we added")
// Verify each member is in the response
memberValues := make([]string, 0, len(respMembers))
for _, member := range respMembers {
memberMap, ok := member.(map[string]interface{})
assert.True(t, ok, "Member should be an object")
memberValues = append(memberValues, memberMap["value"].(string))
assert.Equal(t, "User", memberMap["type"])
}
for _, userID := range userIDs {
assert.Contains(t, memberValues, userID, "User ID should be in the members list")
}
manyMembersGroupID := manyMembersGroupResp["id"].(string)
assert.NotEmpty(t, manyMembersGroupID)
// Test 3: Create a group with externalId
externalIDGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "External ID Group",
"externalId": "external-system-group-789",
"members": []map[string]interface{}{
{
"value": userIDs[0], // Just add the first user as a member
},
},
}
var externalIDGroupResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), externalIDGroupPayload, http.StatusCreated, &externalIDGroupResp)
// Verify the created group
assert.Equal(t, "External ID Group", externalIDGroupResp["displayName"])
assert.Equal(t, "external-system-group-789", externalIDGroupResp["externalId"])
// Verify members
externalIDGroupMembers, ok := externalIDGroupResp["members"].([]interface{})
assert.True(t, ok, "Members should be an array")
assert.Equal(t, 1, len(externalIDGroupMembers), "Should have 1 member")
externalIDGroupID := externalIDGroupResp["id"].(string)
assert.NotEmpty(t, externalIDGroupID)
// Test 4: Try to create a group with the same display name (should fail)
duplicateGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Empty Group", // Same as the first group
}
var errorResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), duplicateGroupPayload, http.StatusConflict, &errorResp)
// Verify error response
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], "One or more of the attribute values are already in use or are reserved")
// Test 4: Try to create a group without displayName (should fail)
noDisplayNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
// No displayName
}
errorResp = nil
s.DoJSON(t, "POST", scimPath("/Groups"), noDisplayNamePayload, http.StatusBadRequest, &errorResp)
// Verify error response
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], "A required value was missing")
// Test 5: Try to create a group with invalid member ID (should fail)
invalidMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Invalid Member Group",
"members": []map[string]interface{}{
{
"value": "invalid-user-id",
},
},
}
errorResp = nil
s.DoJSON(t, "POST", scimPath("/Groups"), invalidMemberPayload, http.StatusBadRequest, &errorResp)
// Verify error response
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
// Delete the groups we created
s.Do(t, "DELETE", scimPath("/Groups/"+emptyGroupID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Groups/"+manyMembersGroupID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Groups/"+externalIDGroupID), nil, http.StatusNoContent)
// Delete the users we created
for _, userID := range userIDs {
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
}
func testCreateUser(t *testing.T, s *Suite) {
// Test creating a user without givenName
userWithoutGivenName := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-given-name@example.com",
"name": map[string]interface{}{
"familyName": "NoGivenName",
},
"emails": []map[string]interface{}{
{
"value": "no-given-name@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var errorResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithoutGivenName, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Equal(t, errors.ScimErrorInvalidValue.Detail, errorResp["detail"])
// Test creating a user with empty givenName
userWithEmptyGivenName := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-given-name@example.com",
"name": map[string]interface{}{
"familyName": "NoGivenName",
"givenName": "",
},
"emails": []map[string]interface{}{
{
"value": "no-given-name@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
s.DoJSON(t, "POST", scimPath("/Users"), userWithEmptyGivenName, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Equal(t, errors.ScimErrorInvalidValue.Detail, errorResp["detail"])
// Test creating a user without familyName
userWithoutFamilyName := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-family-name@example.com",
"name": map[string]interface{}{
"givenName": "NoFamilyName",
},
"emails": []map[string]interface{}{
{
"value": "no-family-name@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
errorResp = nil
s.DoJSON(t, "POST", scimPath("/Users"), userWithoutFamilyName, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Equal(t, errors.ScimErrorInvalidValue.Detail, errorResp["detail"])
// Test creating a user with empty familyName
userWithEmptyFamilyName := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-family-name@example.com",
"name": map[string]interface{}{
"givenName": "NoFamilyName",
"familyName": "",
},
"emails": []map[string]interface{}{
{
"value": "no-family-name@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
errorResp = nil
s.DoJSON(t, "POST", scimPath("/Users"), userWithEmptyFamilyName, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Equal(t, errors.ScimErrorInvalidValue.Detail, errorResp["detail"])
// Test creating a user without emails
userWithoutEmails := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-emails@example.com",
"name": map[string]interface{}{
"givenName": "No",
"familyName": "Emails",
},
"active": true,
}
var createResp3 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithoutEmails, http.StatusCreated, &createResp3)
assert.Equal(t, "no-emails@example.com", createResp3["userName"])
userID3 := createResp3["id"].(string)
// Verify emails is not present or empty
_, hasEmails := createResp3["emails"]
assert.False(t, hasEmails, "emails should not be present")
// Test creating a user without active status
userWithoutActive := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "no-active@example.com",
"name": map[string]interface{}{
"givenName": "No",
"familyName": "Active",
},
"emails": []map[string]interface{}{
{
"value": "no-active@example.com",
"type": "work",
"primary": true,
},
},
}
var createResp4 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithoutActive, http.StatusCreated, &createResp4)
assert.Equal(t, "no-active@example.com", createResp4["userName"])
userID4 := createResp4["id"].(string)
// Verify active is not present or nil
_, hasActive := createResp4["active"]
assert.False(t, hasActive, "active should not be present")
// Test creating a user with multiple emails
userWithMultipleEmails := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "multiple-emails@example.com",
"name": map[string]interface{}{
"givenName": "Multiple",
"familyName": "Emails",
},
"emails": []map[string]interface{}{
{
"value": "multiple-emails@example.com",
"type": "work",
"primary": true,
},
{
"value": "multiple-emails-home@example.com",
"type": "home",
"primary": false,
},
{
"value": "multiple-emails-other@example.com",
"type": "other",
"primary": false,
},
},
"active": true,
}
var createResp5 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithMultipleEmails, http.StatusCreated, &createResp5)
assert.Equal(t, "multiple-emails@example.com", createResp5["userName"])
userID5 := createResp5["id"].(string)
// Verify multiple emails are present
emails, ok := createResp5["emails"].([]interface{})
assert.True(t, ok, "Emails should be an array")
assert.Equal(t, 3, len(emails), "Should have 3 emails")
// Test creating a user with empty userName
userWithEmptyUserName := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "", // Empty userName
"name": map[string]interface{}{
"givenName": "Empty",
"familyName": "UserName",
},
"emails": []map[string]interface{}{
{
"value": "empty-username@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var errorResp1 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithEmptyUserName, http.StatusBadRequest, &errorResp1)
// Verify error response
assert.EqualValues(t, errorResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp1["detail"], "Bad Request")
// Test creating a user with duplicate userName
duplicateUserNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "multiple-emails@example.com", // Same as userWithMultipleEmails
"name": map[string]interface{}{
"givenName": "Duplicate",
"familyName": "UserName",
},
"emails": []map[string]interface{}{
{
"value": "duplicate@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var errorResp2 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), duplicateUserNamePayload, http.StatusConflict, &errorResp2)
// Verify error response
assert.EqualValues(t, errorResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp2["detail"], "One or more of the attribute values are already in use or are reserved")
// Test creating a user with duplicate userName using different case.
// userName must be case insensitive
duplicateUserNamePayload = map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "Multiple-Emails@example.com", // Same as userWithMultipleEmails
"name": map[string]interface{}{
"givenName": "Duplicate",
"familyName": "UserName",
},
"emails": []map[string]interface{}{
{
"value": "duplicate@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var errorResp3 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), duplicateUserNamePayload, http.StatusConflict, &errorResp3)
// Verify error response
assert.EqualValues(t, errorResp3["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp3["detail"], "One or more of the attribute values are already in use or are reserved")
// Test creating a user with externalId
userWithExternalID := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "external-id-user@example.com",
"externalId": "external-system-123456",
"name": map[string]interface{}{
"givenName": "External",
"familyName": "IDUser",
},
"emails": []map[string]interface{}{
{
"value": "external-id-user@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var createResp6 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithExternalID, http.StatusCreated, &createResp6)
assert.Equal(t, "external-id-user@example.com", createResp6["userName"])
userID6 := createResp6["id"].(string)
// Verify externalId is present and correct
assert.Equal(t, "external-system-123456", createResp6["externalId"])
// Test creating a user with department
userWithDepartment := map[string]interface{}{
"schemas": []string{
"urn:ietf:params:scim:schemas:core:2.0:User",
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
},
"userName": "user-with-department@example.com",
"name": map[string]interface{}{
"givenName": "Foo",
"familyName": "Bar",
},
"emails": []map[string]interface{}{
{
"value": "foobar@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User": map[string]interface{}{
"department": "Engineering",
},
}
var createResp7 map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), userWithDepartment, http.StatusCreated, &createResp7)
assert.Equal(t, "user-with-department@example.com", createResp7["userName"])
userID7 := createResp7["id"].(string)
// Verify department is present and correct
m_, ok := createResp7["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
require.True(t, ok)
m, ok := m_.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "Engineering", m["department"])
// Make sure these users can be deleted.
s.Do(t, "DELETE", scimPath("/Users/"+userID3), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+userID4), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+userID5), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+userID6), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+userID7), nil, http.StatusNoContent)
}
func testUpdateUser(t *testing.T, s *Suite) {
// Create first user
firstUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "first-user@example.com",
"name": map[string]interface{}{
"givenName": "First",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": "first-user@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var firstUserResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), firstUserPayload, http.StatusCreated, &firstUserResp)
firstUserID := firstUserResp["id"].(string)
assert.NotEmpty(t, firstUserID)
// Create second user
secondUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "second-user@example.com",
"name": map[string]interface{}{
"givenName": "Second",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": "second-user@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var secondUserResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), secondUserPayload, http.StatusCreated, &secondUserResp)
secondUserID := secondUserResp["id"].(string)
assert.NotEmpty(t, secondUserID)
// Test 1: Try to update first user's userName to be exactly the same as second user's userName
updatePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "second-user@example.com", // Same as second user
"name": map[string]interface{}{
"givenName": "First",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": "first-user@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var errorResp1 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusConflict, &errorResp1)
// Verify error response
assert.EqualValues(t, errorResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp1["detail"], "One or more of the attribute values are already in use or are reserved")
// Test 2: Try to update first user's userName to be a case-randomized version of second user's userName
updatePayload["userName"] = "SeCoNd-UsEr@ExAmPlE.cOm" // Case-randomized version of second user's userName
var errorResp2 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusConflict, &errorResp2)
// Verify error response
assert.EqualValues(t, errorResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp2["detail"], "One or more of the attribute values are already in use or are reserved")
// Test 3: Try to update first user's userName to be exactly the same as its current userName (should succeed)
updatePayload["userName"] = "first-user@example.com" // Same as current userName
var updateResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusOK, &updateResp)
// Verify the update was successful
assert.Equal(t, "first-user@example.com", updateResp["userName"])
// Test 4: Try to update first user's userName to be a case-randomized version of its current userName (should succeed)
updatePayload["userName"] = "FiRsT-uSeR@eXaMpLe.CoM" // Case-randomized version of current userName
var updateResp2 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusOK, &updateResp2)
// Verify the update was successful.
assert.Equal(t, "FiRsT-uSeR@eXaMpLe.CoM", updateResp2["userName"])
// Test 5: Try to update first user's department (should succeed)
schemas_, ok := updatePayload["schemas"]
require.True(t, ok)
schemas, ok := schemas_.([]string)
require.True(t, ok)
updatePayload["schemas"] = append(schemas, "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User")
updatePayload["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"] = map[string]interface{}{
"department": "Engineering",
}
var updateResp3 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusOK, &updateResp3)
// Verify the update was successful.
m_, ok := updateResp3["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
require.True(t, ok)
m, ok := m_.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "Engineering", m["department"])
// Test 6: Try to update first user's givenName to be empty (should fail)
updatePayload["name"] = map[string]interface{}{
"givenName": "",
"familyName": "User",
}
var errorResp3 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusBadRequest, &errorResp3)
// Verify error response
assert.EqualValues(t, errorResp3["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp3["detail"], "A required value was missing")
// Test 7: Try to update first user's familyName to be empty (should fail)
updatePayload["name"] = map[string]interface{}{
"givenName": "First",
"familyName": "",
}
var errorResp4 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Users/"+firstUserID), updatePayload, http.StatusBadRequest, &errorResp4)
// Verify error response
assert.EqualValues(t, errorResp4["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp4["detail"], "A required value was missing")
// Delete the users we created.
s.Do(t, "DELETE", scimPath("/Users/"+firstUserID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+secondUserID), nil, http.StatusNoContent)
}
func testUpdateGroup(t *testing.T, s *Suite) {
// Create a test user to be added as a member of the groups
createUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "group-update-test@example.com",
"name": map[string]interface{}{
"givenName": "Group",
"familyName": "UpdateTest",
},
"emails": []map[string]interface{}{
{
"value": "group-update-test@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var createUserResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createUserResp)
userID := createUserResp["id"].(string)
assert.NotEmpty(t, userID)
// Create first group
firstGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "First Test Group",
"members": []map[string]interface{}{
{
"value": userID,
},
},
}
var firstGroupResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), firstGroupPayload, http.StatusCreated, &firstGroupResp)
firstGroupID := firstGroupResp["id"].(string)
assert.NotEmpty(t, firstGroupID)
// Create second group
secondGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Second Test Group",
"members": []map[string]interface{}{
{
"value": userID,
},
},
}
var secondGroupResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), secondGroupPayload, http.StatusCreated, &secondGroupResp)
secondGroupID := secondGroupResp["id"].(string)
assert.NotEmpty(t, secondGroupID)
// Test 1: Try to update first group's displayName to be exactly the same as second group's displayName
updatePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Second Test Group", // Same as second group
"members": []map[string]interface{}{
{
"value": userID,
},
},
}
var errorResp1 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusConflict, &errorResp1)
// Verify error response
assert.EqualValues(t, errorResp1["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp1["detail"], "One or more of the attribute values are already in use or are reserved")
// Test 2: Try to update first group's displayName to be a case-randomized version of second group's displayName
updatePayload["displayName"] = "SeCoNd TeSt GrOuP" // Case-randomized version of second group's displayName
var errorResp2 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusConflict, &errorResp2)
// Verify error response
assert.EqualValues(t, errorResp2["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp2["detail"], "One or more of the attribute values are already in use or are reserved")
// Test 3: Try to update first group's displayName to be exactly the same as its current displayName (should succeed)
updatePayload["displayName"] = "First Test Group" // Same as current displayName
var updateResp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusOK, &updateResp)
// Verify the update was successful
assert.Equal(t, "First Test Group", updateResp["displayName"])
// Test 4: Try to update first group's displayName to be a case-randomized version of its current displayName (should succeed)
updatePayload["displayName"] = "FiRsT TeSt GrOuP" // Case-randomized version of current displayName
var updateResp2 map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+firstGroupID), updatePayload, http.StatusOK, &updateResp2)
// Verify the update was successful but the displayName is normalized
assert.Equal(t, "FiRsT TeSt GrOuP", updateResp2["displayName"])
// Delete the users and groups we created.
s.Do(t, "DELETE", scimPath("/Groups/"+firstGroupID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Groups/"+secondGroupID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
func testUsersPagination(t *testing.T, s *Suite) {
// Create multiple users for pagination testing
userIDs := make([]string, 0, 10)
for i := 1; i <= 10; i++ {
userName := fmt.Sprintf("pagination-user-%d@example.com", i)
userID, _ := createTestUser(t, s, userName)
userIDs = append(userIDs, userID)
}
// Test 1: Get first page with 3 users per page
var page1Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &page1Resp, "startIndex", "1", "count", "3")
// Verify response structure
assert.EqualValues(t, page1Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page1Resp["totalResults"], "Total results should be 10")
// Verify resources
resources1, ok := page1Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources1), "First page should have 3 users")
// Verify the users on the first page
userNames1 := make([]string, 0, 3)
for _, resource := range resources1 {
user, ok := resource.(map[string]interface{})
assert.True(t, ok, "User should be an object")
userName, ok := user["userName"].(string)
assert.True(t, ok, "userName should be a string")
userNames1 = append(userNames1, userName)
}
assert.Contains(t, userNames1, "pagination-user-1@example.com", "First page should contain user 1")
assert.Contains(t, userNames1, "pagination-user-2@example.com", "First page should contain user 2")
assert.Contains(t, userNames1, "pagination-user-3@example.com", "First page should contain user 3")
// Test 2: Get second page with 3 users per page
var page2Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &page2Resp, "startIndex", "4", "count", "3")
// Verify response structure
assert.EqualValues(t, page2Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page2Resp["totalResults"], "Total results should be 10")
// Verify resources
resources2, ok := page2Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources2), "Second page should have 3 users")
// Verify the users on the second page
userNames2 := make([]string, 0, 3)
for _, resource := range resources2 {
user, ok := resource.(map[string]interface{})
assert.True(t, ok, "User should be an object")
userName, ok := user["userName"].(string)
assert.True(t, ok, "userName should be a string")
userNames2 = append(userNames2, userName)
}
assert.Contains(t, userNames2, "pagination-user-4@example.com", "Second page should contain user 4")
assert.Contains(t, userNames2, "pagination-user-5@example.com", "Second page should contain user 5")
assert.Contains(t, userNames2, "pagination-user-6@example.com", "Second page should contain user 6")
// Test 3: Get third page with 3 users per page
var page3Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &page3Resp, "startIndex", "7", "count", "3")
// Verify response structure
assert.EqualValues(t, page3Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page3Resp["totalResults"], "Total results should be 10")
// Verify resources
resources3, ok := page3Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources3), "Third page should have 3 users")
// Verify the users on the third page
userNames3 := make([]string, 0, 3)
for _, resource := range resources3 {
user, ok := resource.(map[string]interface{})
assert.True(t, ok, "User should be an object")
userName, ok := user["userName"].(string)
assert.True(t, ok, "userName should be a string")
userNames3 = append(userNames3, userName)
}
assert.Contains(t, userNames3, "pagination-user-7@example.com", "Third page should contain user 7")
assert.Contains(t, userNames3, "pagination-user-8@example.com", "Third page should contain user 8")
assert.Contains(t, userNames3, "pagination-user-9@example.com", "Third page should contain user 9")
// Test 4: Get fourth page with 3 users per page (should contain only 1 user)
var page4Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &page4Resp, "startIndex", "10", "count", "3")
// Verify response structure
assert.EqualValues(t, page4Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page4Resp["totalResults"], "Total results should be 10")
// Verify resources
resources4, ok := page4Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
require.Len(t, resources4, 1, "Fourth page should have 1 user")
// Verify the user on the fourth page
user4, ok := resources4[0].(map[string]interface{})
assert.True(t, ok, "User should be an object")
userName4, ok := user4["userName"].(string)
assert.True(t, ok, "userName should be a string")
assert.Equal(t, "pagination-user-10@example.com", userName4, "Fourth page should contain user 10")
// Test 5: Get page with startIndex beyond the total results (should return empty resources)
var emptyPageResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &emptyPageResp, "startIndex", "11", "count", "3")
// Verify response structure
assert.EqualValues(t, emptyPageResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), emptyPageResp["totalResults"], "Total results should be 10")
// Verify resources
emptyResources, ok := emptyPageResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Empty(t, emptyResources, "Page beyond total results should have 0 users")
// Test 6: Get all users in a single page
var allUsersResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users"), nil, http.StatusOK, &allUsersResp, "count", "20")
// Verify response structure
assert.EqualValues(t, allUsersResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), allUsersResp["totalResults"], "Total results should be 10")
// Verify resources
allResources, ok := allUsersResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 10, len(allResources), "All users page should have 10 users")
// Delete all created users
for _, userID := range userIDs {
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
}
func testGroupsPagination(t *testing.T, s *Suite) {
// First, create a user to be added as a member of some groups
userID, _ := createTestUser(t, s, "group-pagination-member@example.com")
// Create multiple groups for pagination testing
groupIDs := make([]string, 0, 10)
for i := 1; i <= 10; i++ {
// Add the user as a member to even-numbered groups
var memberIDs []string
if i%2 == 0 {
memberIDs = []string{userID}
}
groupID, _ := createTestGroup(t, s, fmt.Sprintf("Pagination Group %d", i), memberIDs)
groupIDs = append(groupIDs, groupID)
}
// Test 1: Get first page with 3 groups per page
var page1Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &page1Resp, "startIndex", "1", "count", "3")
// Verify response structure
assert.EqualValues(t, page1Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page1Resp["totalResults"], "Total results should be 10")
// Verify resources
resources1, ok := page1Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources1), "First page should have 3 groups")
// Verify the groups on the first page
displayNames1 := make([]string, 0, 3)
for _, resource := range resources1 {
group, ok := resource.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
displayName, ok := group["displayName"].(string)
assert.True(t, ok, "displayName should be a string")
displayNames1 = append(displayNames1, displayName)
}
assert.Contains(t, displayNames1, "Pagination Group 1", "First page should contain group 1")
assert.Contains(t, displayNames1, "Pagination Group 2", "First page should contain group 2")
assert.Contains(t, displayNames1, "Pagination Group 3", "First page should contain group 3")
// Test 2: Get second page with 3 groups per page
var page2Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &page2Resp, "startIndex", "4", "count", "3")
// Verify response structure
assert.EqualValues(t, page2Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page2Resp["totalResults"], "Total results should be 10")
// Verify resources
resources2, ok := page2Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources2), "Second page should have 3 groups")
// Verify the groups on the second page
displayNames2 := make([]string, 0, 3)
for _, resource := range resources2 {
group, ok := resource.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
displayName, ok := group["displayName"].(string)
assert.True(t, ok, "displayName should be a string")
displayNames2 = append(displayNames2, displayName)
}
assert.Contains(t, displayNames2, "Pagination Group 4", "Second page should contain group 4")
assert.Contains(t, displayNames2, "Pagination Group 5", "Second page should contain group 5")
assert.Contains(t, displayNames2, "Pagination Group 6", "Second page should contain group 6")
// Test 3: Get third page with 3 groups per page
var page3Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &page3Resp, "startIndex", "7", "count", "3")
// Verify response structure
assert.EqualValues(t, page3Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page3Resp["totalResults"], "Total results should be 10")
// Verify resources
resources3, ok := page3Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 3, len(resources3), "Third page should have 3 groups")
// Verify the groups on the third page
displayNames3 := make([]string, 0, 3)
for _, resource := range resources3 {
group, ok := resource.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
displayName, ok := group["displayName"].(string)
assert.True(t, ok, "displayName should be a string")
displayNames3 = append(displayNames3, displayName)
}
assert.Contains(t, displayNames3, "Pagination Group 7", "Third page should contain group 7")
assert.Contains(t, displayNames3, "Pagination Group 8", "Third page should contain group 8")
assert.Contains(t, displayNames3, "Pagination Group 9", "Third page should contain group 9")
// Test 4: Get fourth page with 3 groups per page (should contain only 1 group)
var page4Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &page4Resp, "startIndex", "10", "count", "3")
// Verify response structure
assert.EqualValues(t, page4Resp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), page4Resp["totalResults"], "Total results should be 10")
// Verify resources
resources4, ok := page4Resp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
require.Len(t, resources4, 1, "Fourth page should have 1 group")
// Verify the group on the fourth page
group4, ok := resources4[0].(map[string]interface{})
assert.True(t, ok, "Group should be an object")
displayName4, ok := group4["displayName"].(string)
assert.True(t, ok, "displayName should be a string")
assert.Equal(t, "Pagination Group 10", displayName4, "Fourth page should contain group 10")
// Test 5: Get page with startIndex beyond the total results (should return empty resources)
var emptyPageResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &emptyPageResp, "startIndex", "11", "count", "3")
// Verify response structure
assert.EqualValues(t, emptyPageResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), emptyPageResp["totalResults"], "Total results should be 10")
// Verify resources
emptyResources, ok := emptyPageResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Empty(t, emptyResources, "Page beyond total results should have 0 groups")
// Test 6: Get all groups in a single page
var allGroupsResp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Groups"), nil, http.StatusOK, &allGroupsResp, "count", "20")
// Verify response structure
assert.EqualValues(t, allGroupsResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:ListResponse"})
assert.Equal(t, float64(10), allGroupsResp["totalResults"], "Total results should be 10")
// Verify resources
allResources, ok := allGroupsResp["Resources"].([]interface{})
assert.True(t, ok, "Resources should be an array")
assert.Equal(t, 10, len(allResources), "All groups page should have 10 groups")
// Test 7: Verify that even-numbered groups have the user as a member
for _, resource := range allResources {
group, ok := resource.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
displayName, ok := group["displayName"].(string)
assert.True(t, ok, "displayName should be a string")
// Extract the group number from the display name
var groupNum int
_, err := fmt.Sscanf(displayName, "Pagination Group %d", &groupNum)
assert.NoError(t, err, "Should be able to extract group number from display name")
// Check if the group has members based on its number
if groupNum%2 == 0 {
// Even-numbered groups should have the user as a member
members, ok := group["members"].([]interface{})
assert.True(t, ok, "members should be an array")
assert.Equal(t, 1, len(members), "Even-numbered group should have 1 member")
if len(members) > 0 {
member, ok := members[0].(map[string]interface{})
assert.True(t, ok, "Member should be an object")
assert.Equal(t, userID, member["value"], "Member should be the test user")
}
} else {
// Odd-numbered groups should not have members
_, hasMembersField := group["members"]
assert.False(t, hasMembersField, "Odd-numbered group should not have members field")
}
}
// Delete all created groups
for _, groupID := range groupIDs {
s.Do(t, "DELETE", scimPath("/Groups/"+groupID), nil, http.StatusNoContent)
}
// Delete the user we created
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
func testUsersAndGroups(t *testing.T, s *Suite) {
// Create multiple test users
userIDs := make([]string, 0, 3)
for i := 1; i <= 3; i++ {
userName := fmt.Sprintf("user-group-test-%d@example.com", i)
userID, _ := createTestUser(t, s, userName)
userIDs = append(userIDs, userID)
}
// Create two groups with different membership patterns
// Group 1: Contains users 1 and 2
group1ID, _ := createTestGroup(t, s, "Test Group 1", []string{userIDs[0], userIDs[1]})
// Group 2: Contains users 2 and 3
group2ID, _ := createTestGroup(t, s, "Test Group 2", []string{userIDs[1], userIDs[2]})
// Test 1: Verify that User 1 is in Group 1 only
var user1Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/"+userIDs[0]), nil, http.StatusOK, &user1Resp)
// Check groups field in user response
user1Groups, ok := user1Resp["groups"].([]interface{})
assert.True(t, ok, "User should have groups field")
assert.Equal(t, 1, len(user1Groups), "User 1 should be in 1 group")
// Verify the group is Group 1
if len(user1Groups) > 0 {
group, ok := user1Groups[0].(map[string]interface{})
assert.True(t, ok, "Group should be an object")
assert.Equal(t, group1ID, group["value"], "User 1 should be in Group 1")
assert.Equal(t, "Groups/"+group1ID, group["$ref"], "Group $ref should be correct")
assert.Equal(t, "Test Group 1", group["display"], "Group display name should be correct")
}
// Test 2: Verify that User 2 is in both Group 1 and Group 2
var user2Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/"+userIDs[1]), nil, http.StatusOK, &user2Resp)
// Check groups field in user response
user2Groups, ok := user2Resp["groups"].([]interface{})
assert.True(t, ok, "User should have groups field")
assert.Equal(t, 2, len(user2Groups), "User 2 should be in 2 groups")
// Verify the groups include both Group 1 and Group 2
groupValues := make([]string, 0, 2)
groupDisplays := make(map[string]string)
for _, g := range user2Groups {
group, ok := g.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
groupID := group["value"].(string)
groupValues = append(groupValues, groupID)
groupDisplays[groupID] = group["display"].(string)
}
assert.Contains(t, groupValues, group1ID, "User 2 should be in Group 1")
assert.Contains(t, groupValues, group2ID, "User 2 should be in Group 2")
assert.Equal(t, "Test Group 1", groupDisplays[group1ID], "Group 1 display name should be correct")
assert.Equal(t, "Test Group 2", groupDisplays[group2ID], "Group 2 display name should be correct")
// Test 3: Verify that User 3 is in Group 2 only
var user3Resp map[string]interface{}
s.DoJSON(t, "GET", scimPath("/Users/"+userIDs[2]), nil, http.StatusOK, &user3Resp)
// Check groups field in user response
user3Groups, ok := user3Resp["groups"].([]interface{})
assert.True(t, ok, "User should have groups field")
assert.Equal(t, 1, len(user3Groups), "User 3 should be in 1 group")
// Verify the group is Group 2
if len(user3Groups) > 0 {
group, ok := user3Groups[0].(map[string]interface{})
assert.True(t, ok, "Group should be an object")
assert.Equal(t, group2ID, group["value"], "User 3 should be in Group 2")
assert.Equal(t, "Groups/"+group2ID, group["$ref"], "Group $ref should be correct")
assert.Equal(t, "Test Group 2", group["display"], "Group display name should be correct")
}
// Test 4: Update Group 1 to remove User 1 and add User 3
updateGroup1Payload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Test Group 1",
"members": []map[string]interface{}{
{
"value": userIDs[1], // Keep User 2
},
{
"value": userIDs[2], // Add User 3
},
},
}
var updateGroup1Resp map[string]interface{}
s.DoJSON(t, "PUT", scimPath("/Groups/"+group1ID), updateGroup1Payload, http.StatusOK, &updateGroup1Resp)
// Test 5: Verify that User 1 is no longer in any group
user1Resp = nil
s.DoJSON(t, "GET", scimPath("/Users/"+userIDs[0]), nil, http.StatusOK, &user1Resp)
// Check groups field in user response
_, hasGroups := user1Resp["groups"]
assert.False(t, hasGroups, "User 1 should not have groups field or it should be empty")
// Test 6: Verify that User 3 is now in both groups
user3Resp = nil
s.DoJSON(t, "GET", scimPath("/Users/"+userIDs[2]), nil, http.StatusOK, &user3Resp)
// Check groups field in user response
user3Groups, ok = user3Resp["groups"].([]interface{})
assert.True(t, ok, "User should have groups field")
assert.Equal(t, 2, len(user3Groups), "User 3 should be in 2 groups")
// Verify the groups include both Group 1 and Group 2
groupValues = make([]string, 0, 2)
groupDisplays = make(map[string]string)
for _, g := range user3Groups {
group, ok := g.(map[string]interface{})
assert.True(t, ok, "Group should be an object")
groupID := group["value"].(string)
groupValues = append(groupValues, groupID)
groupDisplays[groupID] = group["display"].(string)
}
assert.Contains(t, groupValues, group1ID, "User 3 should be in Group 1")
assert.Contains(t, groupValues, group2ID, "User 3 should be in Group 2")
assert.Equal(t, "Test Group 1", groupDisplays[group1ID], "Group 1 display name should be correct")
assert.Equal(t, "Test Group 2", groupDisplays[group2ID], "Group 2 display name should be correct")
// Delete the groups we created
s.Do(t, "DELETE", scimPath("/Groups/"+group1ID), nil, http.StatusNoContent)
s.Do(t, "DELETE", scimPath("/Groups/"+group2ID), nil, http.StatusNoContent)
// Delete the users we created
for _, userID := range userIDs {
s.Do(t, "DELETE", scimPath("/Users/"+userID), nil, http.StatusNoContent)
}
}
func testPatchUserEmails(t *testing.T, s *Suite) {
// Create a test user with initial email
createUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "patch-emails-test@example.com",
"name": map[string]interface{}{
"givenName": "Patch",
"familyName": "EmailsTest",
},
"emails": []map[string]interface{}{
{
"value": "patch-emails-test@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createResp)
userID := createResp["id"].(string)
t.Run("Patch the user to replace emails with a new set of emails", func(t *testing.T) {
patchEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "new-primary@example.com",
"type": "work",
"primary": true,
},
{
"value": "secondary@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchEmailsPayload, http.StatusOK, &patchResp)
// Verify the emails were updated
emails, _ := patchResp["emails"].([]interface{})
assert.Equal(t, 2, len(emails), "Should have 2 emails after patch")
// Verify the email values
emailValues := make([]string, 0, 2)
primaryFound := false
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
emailValues = append(emailValues, email["value"].(string))
// Check if this is the primary email
if primary, ok := email["primary"].(bool); ok && primary {
primaryFound = true
assert.Equal(t, "new-primary@example.com", email["value"], "Primary email should be new-primary@example.com")
assert.Equal(t, "work", email["type"], "Primary email should be of type work")
}
}
assert.EqualValues(t, []string{"new-primary@example.com", "secondary@example.com"}, emailValues,
"Emails should be new-primary@example.com and secondary@example.com")
assert.True(t, primaryFound, "One email should be marked as primary")
})
t.Run("Verify that patching with no primary email is allowed", func(t *testing.T) {
patchNoPrimaryPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "no-primary1@example.com",
"type": "work",
// No primary field
},
{
"value": "no-primary2@example.com",
"type": "home",
// No primary field
},
},
},
},
},
}
var noPrimaryResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchNoPrimaryPayload, http.StatusOK, &noPrimaryResp)
// Verify the emails were updated
noPrimaryEmails, _ := noPrimaryResp["emails"].([]interface{})
assert.Equal(t, 2, len(noPrimaryEmails), "Should have 2 emails after patch")
// Verify the email values
noPrimaryEmailValues := make([]string, 0, 2)
for _, e := range noPrimaryEmails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
noPrimaryEmailValues = append(noPrimaryEmailValues, email["value"].(string))
}
assert.EqualValues(t, []string{"no-primary1@example.com", "no-primary2@example.com"}, noPrimaryEmailValues,
"Emails should be no-primary1@example.com and no-primary2@example.com")
})
t.Run("Verify that patching with an empty emails array removes all emails", func(t *testing.T) {
patchEmptyEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{},
},
},
},
}
var emptyEmailsResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchEmptyEmailsPayload, http.StatusOK, &emptyEmailsResp)
// Verify the emails were updated (should be empty or not present)
noEmails := emptyEmailsResp["emails"]
assert.Empty(t, noEmails, "Emails should be empty after patch")
})
t.Run("Patch emails with explicit path", func(t *testing.T) {
patchEmailsWithPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "emails",
"value": []map[string]interface{}{
{
"value": "explicit-path-primary@example.com",
"type": "work",
"primary": true,
},
{
"value": "explicit-path-secondary@example.com",
"type": "home",
"primary": false,
},
},
},
},
}
var patchEmailsWithPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchEmailsWithPathPayload, http.StatusOK, &patchEmailsWithPathResp)
emails, ok := patchEmailsWithPathResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should have 2 emails after patch with explicit path")
// Verify the email values
emailValues := make([]string, 0, 2)
primaryFound := false
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
emailValues = append(emailValues, email["value"].(string))
// Check if this is the primary email
if primary, ok := email["primary"].(bool); ok && primary {
primaryFound = true
assert.Equal(t, "explicit-path-primary@example.com", email["value"], "Primary email should be explicit-path-primary@example.com")
assert.Equal(t, "work", email["type"], "Primary email should be of type work")
}
}
assert.EqualValues(t, []string{"explicit-path-primary@example.com", "explicit-path-secondary@example.com"}, emailValues,
"Email values should be updated.")
assert.True(t, primaryFound, "One email should be marked as primary")
})
t.Run("Add a new email with path specified", func(t *testing.T) {
// First, ensure we have a clean starting state with one email
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Now add a new email with path specified
addEmailPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "emails",
"value": []map[string]interface{}{
{
"value": "added-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
}
var addEmailResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addEmailPayload, http.StatusOK, &addEmailResp)
// Verify both emails are present
emails, ok := addEmailResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should have 2 emails after adding one")
// Check that both the original and new emails are present
emailValues := make([]string, 0, 2)
emailTypes := make(map[string]string)
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
emailValue := email["value"].(string)
emailValues = append(emailValues, emailValue)
emailTypes[emailValue] = email["type"].(string)
}
assert.Contains(t, emailValues, "work-email@example.com", "Original work email should still be present")
assert.Contains(t, emailValues, "added-email@example.com", "Added home email should be present")
assert.Equal(t, "work", emailTypes["work-email@example.com"], "Original email should be of type work")
assert.Equal(t, "home", emailTypes["added-email@example.com"], "Added email should be of type home")
})
t.Run("Add a new email without path specified", func(t *testing.T) {
// First, ensure we have a clean starting state with one email
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Now add a new email without path specified (should add to the resource)
addEmailPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "added-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var addEmailResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addEmailPayload, http.StatusOK, &addEmailResp)
// Verify both emails are present
emails, ok := addEmailResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should have 2 emails after adding one")
// Check that both the original and new emails are present
emailValues := make([]string, 0, 2)
emailTypes := make(map[string]string)
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
emailValue := email["value"].(string)
emailValues = append(emailValues, emailValue)
emailTypes[emailValue] = email["type"].(string)
}
assert.Contains(t, emailValues, "work-email@example.com", "Original work email should still be present")
assert.Contains(t, emailValues, "added-email@example.com", "Added home email should be present")
assert.Equal(t, "work", emailTypes["work-email@example.com"], "Original email should be of type work")
assert.Equal(t, "home", emailTypes["added-email@example.com"], "Added email should be of type home")
})
t.Run("Remove an email by type filter", func(t *testing.T) {
// First, ensure we have both work and home emails
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Delete the home email using a type filter
deleteEmailPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": `emails[type eq "home"]`,
},
},
}
var deleteEmailResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), deleteEmailPayload, http.StatusOK, &deleteEmailResp)
// Verify only work email remains
emails, ok := deleteEmailResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 1, len(emails), "Should have only 1 email after deleting home email")
// Check that only the work email remains
email, ok := emails[0].(map[string]interface{})
assert.True(t, ok, "Email should be an object")
assert.Equal(t, "work-email@example.com", email["value"], "Work email should remain")
assert.Equal(t, "work", email["type"], "Remaining email should be of type work")
assert.Equal(t, true, email["primary"], "Work email should be primary")
})
t.Run("Remove all emails", func(t *testing.T) {
// First, ensure we have both work and home emails
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Delete all emails by removing the entire emails attribute
deleteAllEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "emails",
},
},
}
var deleteAllEmailsResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), deleteAllEmailsPayload, http.StatusOK, &deleteAllEmailsResp)
// Verify emails attribute is not present or is empty
_, hasEmails := deleteAllEmailsResp["emails"]
assert.False(t, hasEmails, "Emails attribute should be removed after deleting all emails")
})
t.Run("Combined add and remove operations in a single request", func(t *testing.T) {
// First, ensure we have both work and home emails
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Perform combined operations: remove home email and add other email
combinedOpsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": `emails[type eq "home"]`,
},
{
"op": "add",
"path": "emails",
"value": []map[string]interface{}{
{
"value": "other-email@example.com",
"type": "other",
"primary": false,
},
},
},
},
}
var combinedOpsResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), combinedOpsPayload, http.StatusOK, &combinedOpsResp)
// Verify we have two emails: work and other (home was removed)
emails, ok := combinedOpsResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should have 2 emails after combined operations")
// Check that the work email remains and other email was added
emailValues := make([]string, 0, 2)
emailTypes := make(map[string]string)
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
emailValue := email["value"].(string)
emailValues = append(emailValues, emailValue)
emailTypes[emailValue] = email["type"].(string)
}
assert.Contains(t, emailValues, "work-email@example.com", "Work email should remain")
assert.Contains(t, emailValues, "other-email@example.com", "Other email should be added")
assert.NotContains(t, emailValues, "home-email@example.com", "Home email should be removed")
assert.Equal(t, "work", emailTypes["work-email@example.com"], "Work email should be of type work")
assert.Equal(t, "other", emailTypes["other-email@example.com"], "Other email should be of type other")
})
t.Run("Patch emails by type filter", func(t *testing.T) {
// First, ensure we have both work and home emails
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Now patch only the work email using the filter
patchWorkEmailPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": `emails[type eq "work"]`,
"value": map[string]interface{}{
"value": "updated-work@example.com",
"type": "work",
"primary": true,
},
},
},
}
var patchWorkResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchWorkEmailPayload, http.StatusOK, &patchWorkResp)
emails, ok := patchWorkResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should still have 2 emails after patching work email")
// Check that only the work email was updated and home email remains unchanged
var workEmail, homeEmail map[string]interface{}
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["type"].(string) == "work" {
workEmail = email
} else if email["type"].(string) == "home" {
homeEmail = email
}
}
require.NotNil(t, workEmail, "Work email should exist")
require.NotNil(t, homeEmail, "Home email should exist")
assert.Equal(t, "updated-work@example.com", workEmail["value"], "Work email should be updated")
assert.Equal(t, "home-email@example.com", homeEmail["value"], "Home email should remain unchanged")
assert.Equal(t, true, workEmail["primary"], "Work email should be primary")
assert.Equal(t, false, homeEmail["primary"], "Home email should not be primary")
})
t.Run("Patch individual field of email by type", func(t *testing.T) {
// First, ensure we have both work and home emails with known values
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Now patch only the primary field of the work email
patchWorkPrimaryPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": `emails[type eq "work"].primary`,
"value": false,
},
},
}
var patchPrimaryResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchWorkPrimaryPayload, http.StatusOK, &patchPrimaryResp)
emails, ok := patchPrimaryResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should still have 2 emails after patching primary flag")
// Check that only the primary flag of work email was updated
var workEmail, homeEmail map[string]interface{}
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["type"].(string) == "work" {
workEmail = email
} else if email["type"].(string) == "home" {
homeEmail = email
}
}
require.NotNil(t, workEmail, "Work email should exist")
require.NotNil(t, homeEmail, "Home email should exist")
assert.Equal(t, "work-email@example.com", workEmail["value"], "Work email value should remain unchanged")
assert.Equal(t, "home-email@example.com", homeEmail["value"], "Home email value should remain unchanged")
assert.Equal(t, false, workEmail["primary"], "Work email primary flag should be updated to false")
assert.Equal(t, false, homeEmail["primary"], "Home email primary flag should remain false")
// Now patch only the value field of the home email
patchHomeValuePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": `emails[type eq "home"].value`,
"value": "updated-home@example.com",
},
},
}
var patchHomeValueResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchHomeValuePayload, http.StatusOK, &patchHomeValueResp)
// Verify the value was updated correctly
emails, ok = patchHomeValueResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should still have 2 emails after patching home email value")
// Check that only the value of home email was updated
workEmail, homeEmail = nil, nil
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["type"].(string) == "work" {
workEmail = email
} else if email["type"].(string) == "home" {
homeEmail = email
}
}
require.NotNil(t, workEmail, "Work email should exist")
require.NotNil(t, homeEmail, "Home email should exist")
assert.Equal(t, "work-email@example.com", workEmail["value"], "Work email value should remain unchanged")
assert.Equal(t, "updated-home@example.com", homeEmail["value"], "Home email value should be updated")
assert.Equal(t, false, workEmail["primary"], "Work email primary flag should remain false")
assert.Equal(t, false, homeEmail["primary"], "Home email primary flag should remain false")
})
t.Run("Patch email type by type", func(t *testing.T) {
// First, ensure we have both work and home emails with known values
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Now patch the type of the home email to "other"
patchHomeTypePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": `emails[type eq "home"].type`,
"value": "other",
},
},
}
var patchTypeResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchHomeTypePayload, http.StatusOK, &patchTypeResp)
// Verify the type was updated correctly
emails, ok := patchTypeResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should still have 2 emails after patching email type")
// Check that the home email type was changed to "other"
var workEmail, otherEmail map[string]interface{}
var homeEmailFound bool
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
switch email["type"].(string) {
case "work":
workEmail = email
case "home":
homeEmailFound = true
case "other":
otherEmail = email
}
}
require.NotNil(t, workEmail, "Work email should exist")
require.NotNil(t, otherEmail, "Other email (formerly home) should exist")
assert.False(t, homeEmailFound, "Home email should no longer exist")
assert.Equal(t, "work-email@example.com", workEmail["value"], "Work email value should remain unchanged")
assert.Equal(t, "home-email@example.com", otherEmail["value"], "Other email value should be the same as the former home email")
assert.Equal(t, "other", otherEmail["type"], "Home email type should be changed to 'other'")
assert.Equal(t, true, workEmail["primary"], "Work email primary flag should remain true")
assert.Equal(t, false, otherEmail["primary"], "Other email primary flag should remain false")
})
t.Run("Add individual email attribute", func(t *testing.T) {
// First, ensure we have a work email with known values
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Add a new email with just the value attribute first, because it is required
addEmailTypePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": `emails[type eq "home"]`,
"value": []map[string]interface{}{
{
"value": "home-email@example.com",
},
},
},
},
}
var addEmailTypeResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addEmailTypePayload, http.StatusOK, &addEmailTypeResp)
// Now modify the type attribute of the email
addEmailValuePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": `emails[type eq "home"].type`,
"value": "other",
},
},
}
var addEmailValueResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addEmailValuePayload, http.StatusOK, &addEmailValueResp)
// Finally add the primary attribute to the email
addEmailPrimaryPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": `emails[type eq "other"].primary`,
"value": true,
},
},
}
var addEmailPrimaryResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addEmailPrimaryPayload, http.StatusOK, &addEmailPrimaryResp)
// Verify both emails are present and home email is now primary
emails, ok := addEmailPrimaryResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
assert.Equal(t, 2, len(emails), "Should have 2 emails")
var workEmail, otherEmail map[string]interface{}
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["type"] == "work" {
workEmail = email
} else if email["type"] == "other" {
otherEmail = email
}
}
require.NotNil(t, workEmail, "Work email should exist")
require.NotNil(t, otherEmail, "Other email should exist")
assert.Equal(t, "work-email@example.com", workEmail["value"], "Work email value should be correct")
assert.Equal(t, "home-email@example.com", otherEmail["value"], "Other email value should be correct")
assert.Equal(t, false, workEmail["primary"], "Work email should no longer be primary")
assert.Equal(t, true, otherEmail["primary"], "Other email should now be primary")
})
t.Run("Remove individual email attribute", func(t *testing.T) {
// First, ensure we have both work and home emails with known values
setupEmailsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "work-email@example.com",
"type": "work",
"primary": true,
},
{
"value": "home-email@example.com",
"type": "home",
"primary": false,
},
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupEmailsPayload, http.StatusOK, &setupResp)
// Remove the primary attribute from the work email
removePrimaryPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": `emails[type eq "work"].primary`,
},
},
}
var removePrimaryResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), removePrimaryPayload, http.StatusOK, &removePrimaryResp)
// Verify the primary attribute was removed
emails, ok := removePrimaryResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
var workEmail map[string]interface{}
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["type"] == "work" {
workEmail = email
break
}
}
require.NotNil(t, workEmail, "Work email should exist")
_, hasPrimary := workEmail["primary"]
assert.False(t, hasPrimary, "Work email should not have primary attribute")
})
// Test failure cases using table-driven tests
t.Run("Email validation failure cases", func(t *testing.T) {
// Define test cases
testCases := []struct {
name string
payload map[string]interface{}
errorMessage string
}{
{
name: "Multiple primary emails",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "primary1@example.com",
"type": "work",
"primary": true,
},
{
"value": "primary2@example.com",
"type": "home",
"primary": true, // Second primary email
},
},
},
},
},
},
errorMessage: "Only one email can be marked as primary",
},
{
name: "Invalid email format",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "not-an-email", // Invalid email format (missing @ and domain)
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: "Bad Request",
},
{
name: "Empty email value",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "", // Empty email value
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: "Bad Request",
},
{
name: "Email missing @ symbol",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "testinvalid.com", // Email missing @ symbol
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: "Bad Request",
},
{
name: "Email value as a number",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": 123, // Number instead of string
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Email value as a boolean",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": true, // Boolean instead of string
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Email type as a number",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "valid@example.com",
"type": 123, // Number instead of string
"primary": true,
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Email type as a boolean",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "valid@example.com",
"type": true, // Boolean instead of string
"primary": true,
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Primary flag as a string",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "valid@example.com",
"type": "work",
"primary": "true", // String instead of boolean
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Primary flag as a number",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": "valid@example.com",
"type": "work",
"primary": 1, // Number instead of boolean
},
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "Null email value",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": []map[string]interface{}{
{
"value": nil, // Null email value
"type": "work",
"primary": true,
},
},
},
},
},
},
errorMessage: "Bad Request. Invalid parameter provided in request",
},
{
name: "Null emails field",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"emails": nil,
},
},
},
},
errorMessage: "A required value was missing",
},
{
name: "Add operation with invalid email path",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
// We only support type as a subattribute filter
"path": `emails[primary eq "true"]`,
"value": map[string]interface{}{
"value": "new-email@example.com",
"type": "work",
"primary": true,
},
},
},
},
errorMessage: "Bad Request",
},
{
name: "Add operation with missing values",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "emails",
"value": []map[string]interface{}{},
},
},
},
errorMessage: "Bad Request",
},
{
name: "Remove operation with invalid email path",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": `emails[type eq "nonexistent"]`,
},
},
},
errorMessage: "Bad Request",
},
}
// Run each test case
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // these failure test cases do not modify state, so they can run in parallel
var errorResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), tc.payload, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], tc.errorMessage)
})
}
})
}
func testPatchUserAttributes(t *testing.T, s *Suite) {
// Create a test user
createUserPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:User"},
"userName": "patch-attributes-test@example.com",
"name": map[string]interface{}{
"givenName": "Original",
"familyName": "User",
},
"emails": []map[string]interface{}{
{
"value": "patch-attributes-test@example.com",
"type": "work",
"primary": true,
},
},
"active": true,
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Users"), createUserPayload, http.StatusCreated, &createResp)
userID := createResp["id"].(string)
t.Run("Patch userName", func(t *testing.T) {
patchUserNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"userName": "new-username@example.com",
},
},
},
}
var patchUserNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchUserNamePayload, http.StatusOK, &patchUserNameResp)
assert.Equal(t, "new-username@example.com", patchUserNameResp["userName"], "userName should be updated")
})
t.Run("Patch entire name object", func(t *testing.T) {
patchNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"name": map[string]interface{}{
"givenName": "CompletelyNew",
"familyName": "FullName",
},
},
},
},
}
var patchNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchNamePayload, http.StatusOK, &patchNameResp)
name, ok := patchNameResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "CompletelyNew", name["givenName"], "givenName should be updated")
assert.Equal(t, "FullName", name["familyName"], "familyName should be updated")
})
t.Run("Patch active status", func(t *testing.T) {
patchActivePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"active": false,
},
},
},
}
var patchActiveResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchActivePayload, http.StatusOK, &patchActiveResp)
assert.Equal(t, false, patchActiveResp["active"], "active should be updated to false")
})
t.Run("Patch multiple attributes at once (userName, name, active)", func(t *testing.T) {
patchMultiplePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"userName": "multi-update@example.com",
"name": map[string]interface{}{
"givenName": "Multiple",
"familyName": "Updates",
},
"active": true,
},
},
},
}
var patchMultipleResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchMultiplePayload, http.StatusOK, &patchMultipleResp)
assert.Equal(t, "multi-update@example.com", patchMultipleResp["userName"], "userName should be updated")
assert.Equal(t, true, patchMultipleResp["active"], "active should be updated to true")
name, ok := patchMultipleResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "Multiple", name["givenName"], "givenName should be updated")
assert.Equal(t, "Updates", name["familyName"], "familyName should be updated")
})
t.Run("Patch department", func(t *testing.T) {
patchDepartmentPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department": "QA",
},
},
},
}
var patchDepartmentResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchDepartmentPayload, http.StatusOK, &patchDepartmentResp)
// Verify department is present and correct
m_, ok := patchDepartmentResp["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
require.True(t, ok)
m, ok := m_.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "QA", m["department"])
// Now remove department using path.
patchDepartmentPayload2 := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
},
},
}
var patchDepartmentResp2 map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchDepartmentPayload2, http.StatusOK, &patchDepartmentResp2)
// Verify department is not present (if there are no extension attributes, then the whole map is not there).
_, ok = patchDepartmentResp2["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
require.False(t, ok)
// Now re-add department using path.
patchDepartmentPayload3 := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User:department",
"value": "Engineering",
},
},
}
var patchDepartmentResp3 map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchDepartmentPayload3, http.StatusOK, &patchDepartmentResp3)
// Verify department is present and correct
m_, ok = patchDepartmentResp3["urn:ietf:params:scim:schemas:extension:enterprise:2.0:User"]
require.True(t, ok)
m, ok = m_.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, "Engineering", m["department"])
})
// ///////////////////////////////////////////////
// Tests for patching with explicit operation path
t.Run("Patch userName with explicit path", func(t *testing.T) {
patchUserNameWithPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "userName",
"value": "explicit-path-username@example.com",
},
},
}
var patchUserNameWithPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchUserNameWithPathPayload, http.StatusOK, &patchUserNameWithPathResp)
assert.Equal(t, "explicit-path-username@example.com", patchUserNameWithPathResp["userName"], "userName should be updated with explicit path")
})
t.Run("Patch name.givenName with explicit path", func(t *testing.T) {
patchGivenNameWithPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "name.givenName",
"value": "ExplicitPathGiven",
},
},
}
var patchGivenNameWithPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchGivenNameWithPathPayload, http.StatusOK, &patchGivenNameWithPathResp)
nameObj1, ok := patchGivenNameWithPathResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "ExplicitPathGiven", nameObj1["givenName"], "givenName should be updated with explicit path")
assert.Equal(t, "Updates", nameObj1["familyName"], "familyName should remain unchanged")
})
t.Run("Patch name.familyName with explicit path", func(t *testing.T) {
patchFamilyNameWithPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "name.familyName",
"value": "ExplicitPathFamily",
},
},
}
var patchFamilyNameWithPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchFamilyNameWithPathPayload, http.StatusOK, &patchFamilyNameWithPathResp)
nameObj2, ok := patchFamilyNameWithPathResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "ExplicitPathGiven", nameObj2["givenName"], "givenName should remain unchanged")
assert.Equal(t, "ExplicitPathFamily", nameObj2["familyName"], "familyName should be updated with explicit path")
})
t.Run("Patch active with explicit path", func(t *testing.T) {
patchActiveWithPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "active",
"value": false,
},
},
}
var patchActiveWithPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), patchActiveWithPathPayload, http.StatusOK, &patchActiveWithPathResp)
assert.Equal(t, false, patchActiveWithPathResp["active"], "active should be updated to false with explicit path")
})
t.Run("Add a new attribute", func(t *testing.T) {
// Add externalId attribute
addExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "externalId",
"value": "external-id-123",
},
},
}
var addExternalIdResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addExternalIdPayload, http.StatusOK, &addExternalIdResp)
assert.Equal(t, "external-id-123", addExternalIdResp["externalId"], "externalId should be added")
})
t.Run("Delete an attribute", func(t *testing.T) {
// First, ensure the user has an externalId
setupExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"externalId": "external-id-to-delete",
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupExternalIdPayload, http.StatusOK, &setupResp)
assert.Equal(t, "external-id-to-delete", setupResp["externalId"], "externalId should be set")
// Now delete the externalId
deleteExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "externalId",
},
},
}
var deleteExternalIdResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), deleteExternalIdPayload, http.StatusOK, &deleteExternalIdResp)
_, hasExternalId := deleteExternalIdResp["externalId"]
assert.False(t, hasExternalId, "externalId should be deleted")
})
t.Run("Add a new attribute without path specified", func(t *testing.T) {
// First, ensure externalId is not present
removeExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "externalId",
},
},
}
var removeResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), removeExternalIdPayload, http.StatusOK, &removeResp)
// Add externalId attribute without path specified
addExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"value": map[string]interface{}{
"externalId": "external-id-no-path",
},
},
},
}
var addExternalIdResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addExternalIdPayload, http.StatusOK, &addExternalIdResp)
assert.Equal(t, "external-id-no-path", addExternalIdResp["externalId"], "externalId should be added without path specified")
})
t.Run("Combined add and remove operations for attributes", func(t *testing.T) {
// Setup initial state with externalId and active attributes
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"externalId": "initial-external-id",
"active": true,
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), setupPayload, http.StatusOK, &setupResp)
assert.Equal(t, "initial-external-id", setupResp["externalId"], "externalId should be set")
assert.Equal(t, true, setupResp["active"], "active should be set to true")
// Perform combined operations: remove externalId and add a new email
combinedOpsPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "externalId",
},
{
"op": "add",
"path": "emails",
"value": []map[string]interface{}{
{
"value": "new-combined-email@example.com",
"type": "work",
"primary": true,
},
},
},
},
}
var combinedOpsResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), combinedOpsPayload, http.StatusOK, &combinedOpsResp)
// Verify externalId is removed and new email is added
_, hasExternalId := combinedOpsResp["externalId"]
assert.False(t, hasExternalId, "externalId should be removed")
emails, ok := combinedOpsResp["emails"].([]interface{})
require.True(t, ok, "Response should have emails array")
// Find the new email
var foundNewEmail bool
for _, e := range emails {
email, ok := e.(map[string]interface{})
assert.True(t, ok, "Email should be an object")
if email["value"] == "new-combined-email@example.com" {
foundNewEmail = true
assert.Equal(t, "work", email["type"], "Email type should be work")
assert.Equal(t, true, email["primary"], "Email should be primary")
}
}
assert.True(t, foundNewEmail, "New email should be added")
})
t.Run("Add userName attribute", func(t *testing.T) {
// Add userName attribute
addUserNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "userName",
"value": "new-username@example.com",
},
},
}
var addUserNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addUserNamePayload, http.StatusOK, &addUserNameResp)
assert.Equal(t, "new-username@example.com", addUserNameResp["userName"], "userName should be updated")
})
t.Run("Add name attributes", func(t *testing.T) {
// Add givenName attribute
addGivenNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "name.givenName",
"value": "NewGiven",
},
},
}
var addGivenNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addGivenNamePayload, http.StatusOK, &addGivenNameResp)
name, ok := addGivenNameResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "NewGiven", name["givenName"], "givenName should be updated")
// Add familyName attribute
addFamilyNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "name.familyName",
"value": "NewFamily",
},
},
}
var addFamilyNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addFamilyNamePayload, http.StatusOK, &addFamilyNameResp)
name, ok = addFamilyNameResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "NewFamily", name["familyName"], "familyName should be updated")
// Add entire name object
addNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "name",
"value": map[string]interface{}{
"givenName": "CompletelyNew",
"familyName": "FullName",
},
},
},
}
var addNameResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), addNamePayload, http.StatusOK, &addNameResp)
name, ok = addNameResp["name"].(map[string]interface{})
require.True(t, ok, "Response should have name object")
assert.Equal(t, "CompletelyNew", name["givenName"], "givenName should be updated")
assert.Equal(t, "FullName", name["familyName"], "familyName should be updated")
})
// Failure tests using table-driven approach
t.Run("Failure cases", func(t *testing.T) {
testCases := []struct {
name string
payload map[string]interface{}
errorMessage string
}{
{
name: "Invalid userName (empty string)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"userName": "", // Empty userName
},
},
},
},
errorMessage: "Bad Request",
},
{
name: "userName as wrong type (number)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"userName": 12345, // Number instead of string
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "name as wrong type (string instead of object)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"name": "John Doe", // String instead of object
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "name.givenName without required familyName",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"name": map[string]interface{}{
"givenName": "NewFirstName",
// Missing familyName
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "givenName as wrong type (number)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"name": map[string]interface{}{
"givenName": 12345, // Number instead of string
"familyName": "NewLastName",
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "familyName as wrong type (boolean)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"name": map[string]interface{}{
"givenName": "NewFirstName",
"familyName": true, // Boolean instead of string
},
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "active as wrong type (string)",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"active": "true", // String instead of boolean
},
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "unsupported operation",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "bad",
"path": "active",
"value": false,
},
},
},
errorMessage: errors.ScimErrorInvalidValue.Detail,
},
{
name: "no path and invalid value format",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
// No path specified
"value": "not-a-map", // Should be a map with attributes
},
},
},
errorMessage: "A required value was missing",
},
{
name: "wrong value type for active using path",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "active",
"value": "not-a-boolean", // Should be a boolean
},
},
},
errorMessage: "A required value was missing",
},
{
name: "Add operation with invalid path",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "nonExistentAttribute",
"value": "some value",
},
},
},
errorMessage: `The "path" attribute was invalid or malformed.`,
},
{
name: "Add operation without value",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "externalId",
// Missing value
},
},
},
errorMessage: "A required value was missing",
},
{
name: "Remove operation without path",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
// Missing path
},
},
},
errorMessage: "A required value was missing",
},
{
name: "Remove required attribute - userName",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "userName",
},
},
},
errorMessage: "Bad Request",
},
{
name: "Remove required attribute - givenName",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "name.givenName",
},
},
},
errorMessage: "Bad Request",
},
{
name: "Remove required attribute - familyName",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "name.familyName",
},
},
},
errorMessage: "Bad Request",
},
{
name: "Remove required attribute - entire name object",
payload: map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "name",
},
},
},
errorMessage: "Bad Request",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel() // Since these failure tests do not modify state, we can run them in parallel
var errorResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Users/"+userID), tc.payload, http.StatusBadRequest, &errorResp)
assert.EqualValues(t, errorResp["schemas"], []interface{}{"urn:ietf:params:scim:api:messages:2.0:Error"})
assert.Contains(t, errorResp["detail"], tc.errorMessage)
})
}
})
}
func testPatchGroupAttributes(t *testing.T, s *Suite) {
// Create a test user to be added as a member of the group
userID, _ := createTestUser(t, s, "group-patch-test@example.com")
// Create a test group
createGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Original Group Name",
"externalId": "original-external-id",
"members": []map[string]interface{}{
{
"value": userID,
},
},
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), createGroupPayload, http.StatusCreated, &createResp)
groupID := createResp["id"].(string)
t.Run("Replace displayName with explicit path", func(t *testing.T) {
patchDisplayNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "displayName",
"value": "Updated Group Name",
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchDisplayNamePayload, http.StatusOK, &patchResp)
assert.Equal(t, "Updated Group Name", patchResp["displayName"], "displayName should be updated")
})
t.Run("Replace externalId with explicit path", func(t *testing.T) {
patchExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "externalId",
"value": "updated-external-id",
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchExternalIdPayload, http.StatusOK, &patchResp)
assert.Equal(t, "updated-external-id", patchResp["externalId"], "externalId should be updated")
})
t.Run("Remove and add operation for externalId", func(t *testing.T) {
// First, remove the externalId
removeExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "externalId",
},
},
}
var removeResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), removeExternalIdPayload, http.StatusOK, &removeResp)
_, hasExternalId := removeResp["externalId"]
assert.False(t, hasExternalId, "externalId should be removed")
// Now add a new externalId
addExternalIdPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "externalId",
"value": "added-external-id",
},
},
}
var addResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), addExternalIdPayload, http.StatusOK, &addResp)
assert.Equal(t, "added-external-id", addResp["externalId"], "externalId should be added")
})
t.Run("Multiple operations in one request", func(t *testing.T) {
multiOperationPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "externalId",
"value": "multi-op-external-id",
},
{
"op": "replace",
"path": "displayName",
"value": "Multi-Op Group Name",
},
},
}
var multiOpResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), multiOperationPayload, http.StatusOK, &multiOpResp)
assert.Equal(t, "multi-op-external-id", multiOpResp["externalId"], "externalId should be added")
assert.Equal(t, "Multi-Op Group Name", multiOpResp["displayName"], "displayName should be updated")
})
t.Run("Operations without path attribute", func(t *testing.T) {
noPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"displayName": "No-Path Group Name",
"externalId": "no-path-external-id",
},
},
},
}
var noPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), noPathPayload, http.StatusOK, &noPathResp)
assert.Equal(t, "no-path-external-id", noPathResp["externalId"], "externalId should be updated")
assert.Equal(t, "No-Path Group Name", noPathResp["displayName"], "displayName should be updated")
})
t.Run("Attempt to remove required displayName attribute", func(t *testing.T) {
removeDisplayNamePayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "displayName",
},
},
}
var errorResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), removeDisplayNamePayload, http.StatusBadRequest, &errorResp)
assert.Contains(t, errorResp["detail"], "Bad Request", "Should return error for removing required attribute")
})
t.Run("Add members to group", func(t *testing.T) {
// First, ensure the group has a known state with just the original user
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userID,
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after setup")
// Now add the second user to the group
secondUserID, _ := createTestUser(t, s, "second-group-patch-test@example.com")
addMembersPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "members",
"value": []map[string]interface{}{
{
"value": secondUserID,
},
},
},
},
}
var addResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), addMembersPayload, http.StatusOK, &addResp)
// Verify both users are now in the group
members, ok = addResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 2, len(members), "Group should have 2 members after adding one")
// Check that both users are present
memberValues := make([]string, 0, 2)
for _, m := range members {
member, ok := m.(map[string]interface{})
assert.True(t, ok, "Member should be an object")
memberValues = append(memberValues, member["value"].(string))
}
assert.Contains(t, memberValues, userID, "Original user should still be in the group")
assert.Contains(t, memberValues, secondUserID, "Second user should be added to the group")
})
t.Run("Replace members in group", func(t *testing.T) {
// First, ensure the group has a known state with multiple members
setupUserID, _ := createTestUser(t, s, "setup-user@example.com")
defer s.Do(t, "DELETE", scimPath("/Users/"+setupUserID), nil, http.StatusNoContent)
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userID,
},
{
"value": setupUserID,
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 2, len(members), "Group should have 2 members after setup")
// Now replace all members with just the new user
newUserID, _ := createTestUser(t, s, "replace-members-test@example.com")
replaceMembersPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": newUserID,
},
},
},
},
}
var replaceResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), replaceMembersPayload, http.StatusOK, &replaceResp)
// Verify only the new user is in the group
members, ok = replaceResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have only 1 member after replacing")
// Check that only the new user is present
member, ok := members[0].(map[string]interface{})
assert.True(t, ok, "Member should be an object")
assert.Equal(t, newUserID, member["value"], "New user should be the only member")
})
t.Run("Remove all members from group", func(t *testing.T) {
// First, ensure the group has members to remove
setupUserID, _ := createTestUser(t, s, "remove-test-user@example.com")
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userID,
},
{
"value": setupUserID,
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 2, len(members), "Group should have 2 members after setup")
// Now remove all members
removeMembersPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "members",
},
},
}
var removeResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), removeMembersPayload, http.StatusOK, &removeResp)
// Verify no members are in the group
_, hasMembers := removeResp["members"]
assert.False(t, hasMembers, "Group should have no members after removing all")
})
t.Run("Add members without path attribute", func(t *testing.T) {
// First, ensure the group has no members
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": "members",
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
_, hasMembers := setupResp["members"]
assert.False(t, hasMembers, "Group should have no members after setup")
// Now add a member without specifying path
addMembersNoPathPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"value": map[string]interface{}{
"members": []map[string]interface{}{
{
"value": userID,
},
},
},
},
},
}
var addNoPathResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), addMembersNoPathPayload, http.StatusOK, &addNoPathResp)
// Verify the user is now in the group
var members []interface{}
var ok bool
members, ok = addNoPathResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after adding")
// Check that the user is present
member, ok := members[0].(map[string]interface{})
assert.True(t, ok, "Member should be an object")
assert.Equal(t, userID, member["value"], "Original user should be added back to the group")
})
t.Run("Invalid member ID format", func(t *testing.T) {
// Try to add a member with invalid ID format
invalidMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "members",
"value": []map[string]interface{}{
{
"value": "invalid-user-id",
},
},
},
},
}
var errorResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), invalidMemberPayload, http.StatusBadRequest, &errorResp)
assert.Contains(t, errorResp["detail"], "Bad Request", "Should return error for invalid member ID format")
})
t.Run("Non-existent member ID", func(t *testing.T) {
// Try to add a member with non-existent ID
nonExistentMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": "members",
"value": []map[string]interface{}{
{
"value": "4294967295", // Non-existent user ID
},
},
},
},
}
var errorResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), nonExistentMemberPayload, http.StatusBadRequest, &errorResp)
assert.Contains(t, errorResp["detail"], "Bad Request", "Should return error for non-existent member ID")
})
}
func testPatchGroupMembers(t *testing.T, s *Suite) {
// Create test users to be added as members
userIDs := make([]string, 0, 3)
for i := 1; i <= 3; i++ {
userName := fmt.Sprintf("group-patch-test-user-%d@example.com", i)
userID, _ := createTestUser(t, s, userName)
userIDs = append(userIDs, userID)
}
// Create a group with the first user as a member
createGroupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Group"},
"displayName": "Patch Members Test Group",
"members": []map[string]interface{}{
{
"value": userIDs[0],
},
},
}
var createResp map[string]interface{}
s.DoJSON(t, "POST", scimPath("/Groups"), createGroupPayload, http.StatusCreated, &createResp)
groupID := createResp["id"].(string)
t.Run("Deduplicate members in replace payload", func(t *testing.T) {
// Setup: Ensure the group has only the first user
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[0],
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after setup")
// Test: Replace payload includes duplicate records of the second user
patchAddMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[1],
},
{
"value": userIDs[0],
},
{
"value": userIDs[1], // Duplicate
},
},
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchAddMemberPayload, http.StatusOK, &patchResp)
// Verify the member was added
members, ok = patchResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 3, len(members), "Group should now have 3 members") // FIXME: Response generated with createGroupResource echoes members from request prior to deduplication. Is this expected?
// Check that both users are in the members list
memberValues := make([]string, 0, len(members))
for _, m := range members {
member, ok := m.(map[string]interface{})
assert.True(t, ok, "Member should be an object")
memberValues = append(memberValues, member["value"].(string))
}
assert.ElementsMatch(t, memberValues, []string{userIDs[1], userIDs[0], userIDs[1]}) // FIXME: See above
// Test: Replace payload omits path includes duplicate records of the third user
patchAddMemberPayload = map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"value": map[string]interface{}{
"members": []map[string]interface{}{
{
"value": userIDs[2],
},
{
"value": userIDs[0],
},
{
"value": userIDs[2], // Duplicate
},
},
},
},
},
}
patchResp = map[string]interface{}{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchAddMemberPayload, http.StatusOK, &patchResp)
// Verify the member was added
members, ok = patchResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 3, len(members), "Group should now have 3 members") // FIXME: Response generated with createGroupResource echoes members from request prior to deduplication. Is this expected?
// Check that both users are in the members list
memberValues = make([]string, 0, len(members))
for _, m := range members {
member, ok := m.(map[string]interface{})
assert.True(t, ok, "Member should be an object")
memberValues = append(memberValues, member["value"].(string))
}
assert.ElementsMatch(t, memberValues, []string{userIDs[2], userIDs[0], userIDs[2]}) // FIXME: See above
})
t.Run("Add a member using path filtering", func(t *testing.T) {
// Setup: Ensure the group has only the first user
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[0],
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after setup")
// Test: Add the second user to the group using path filtering
patchAddMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "add",
"path": fmt.Sprintf(`members[value eq "%s"]`, userIDs[1]),
"value": map[string]interface{}{},
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchAddMemberPayload, http.StatusOK, &patchResp)
// Verify the member was added
members, ok = patchResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 2, len(members), "Group should now have 2 members")
// Check that both users are in the members list
memberValues := make([]string, 0, 2)
for _, m := range members {
member, ok := m.(map[string]interface{})
assert.True(t, ok, "Member should be an object")
memberValues = append(memberValues, member["value"].(string))
}
assert.Contains(t, memberValues, userIDs[0], "First user should still be a member")
assert.Contains(t, memberValues, userIDs[1], "Second user should be added as a member")
})
t.Run("Remove a member using path filtering", func(t *testing.T) {
// Setup: Ensure the group has both first and second users
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[0],
},
{
"value": userIDs[1],
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 2, len(members), "Group should have 2 members after setup")
// Test: Remove the first user from the group using path filtering
patchRemoveMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": fmt.Sprintf(`members[value eq "%s"]`, userIDs[0]),
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchRemoveMemberPayload, http.StatusOK, &patchResp)
// Verify the member was removed
members, ok = patchResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should now have 1 member")
// Check that only the second user is in the members list
member, ok := members[0].(map[string]interface{})
assert.True(t, ok, "Member should be an object")
assert.Equal(t, userIDs[1], member["value"], "Only the second user should remain as a member")
})
t.Run("Replace a member using path filtering", func(t *testing.T) {
// Setup: Ensure the group has only the second user
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[1],
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after setup")
// Test: Replace the second user with the third user using path filtering
patchReplaceMemberPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": fmt.Sprintf(`members[value eq "%s"]`, userIDs[1]),
},
{
"op": "add",
"path": fmt.Sprintf(`members[value eq "%s"]`, userIDs[2]),
"value": map[string]interface{}{},
},
},
}
var patchResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchReplaceMemberPayload, http.StatusOK, &patchResp)
// Verify the member was replaced
members, ok = patchResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should still have 1 member")
// Check that only the third user is in the members list
member, ok := members[0].(map[string]interface{})
assert.True(t, ok, "Member should be an object")
assert.Equal(t, userIDs[2], member["value"], "Only the third user should be a member")
})
t.Run("Try to remove a non-existent member", func(t *testing.T) {
// Setup: Ensure the group has only the third user
setupPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "replace",
"path": "members",
"value": []map[string]interface{}{
{
"value": userIDs[2],
},
},
},
},
}
var setupResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), setupPayload, http.StatusOK, &setupResp)
// Verify setup was successful
members, ok := setupResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member after setup")
// Test: Try to remove a user that is not a member
patchRemoveNonExistentPayload := map[string]interface{}{
"schemas": []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"},
"Operations": []map[string]interface{}{
{
"op": "remove",
"path": fmt.Sprintf(`members[value eq "%s"]`, userIDs[0]),
},
},
}
var removeMemberResp map[string]interface{}
s.DoJSON(t, "PATCH", scimPath("/Groups/"+groupID), patchRemoveNonExistentPayload, http.StatusOK, &removeMemberResp)
members, ok = removeMemberResp["members"].([]interface{})
require.True(t, ok, "Response should have members array")
assert.Equal(t, 1, len(members), "Group should have 1 member")
})
}
func scimPath(suffix string) string {
paths := []string{"/api/v1/fleet/scim", "/api/latest/fleet/scim"}
prefix := paths[time.Now().UnixNano()%int64(len(paths))]
return prefix + suffix
}