// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. package api4 import ( "context" "encoding/json" "fmt" "strings" "testing" "time" "github.com/mattermost/mattermost/server/public/model" "github.com/stretchr/testify/require" ) func TestCreateCPAField(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }) defer th.TearDown() th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { field := &model.PropertyField{Name: model.NewId(), Type: model.PropertyFieldTypeText} createdField, resp, err := client.CreateCPAField(context.Background(), field) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, createdField) }, "endpoint should not work if no valid license is present") // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("a user without admin permissions should not be able to create a field", func(t *testing.T) { field := &model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, } _, resp, err := th.Client.CreateCPAField(context.Background(), field) CheckForbiddenStatus(t, resp) require.Error(t, err) }) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { field := &model.PropertyField{Name: model.NewId()} createdField, resp, err := client.CreateCPAField(context.Background(), field) CheckBadRequestStatus(t, resp) require.Error(t, err) require.Empty(t, createdField) }, "an invalid field should be rejected") th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { webSocketClient := th.CreateConnectedWebSocketClient(t) name := model.NewId() field := &model.PropertyField{ Name: fmt.Sprintf(" %s\t", name), // name should be sanitized Type: model.PropertyFieldTypeText, Attrs: map[string]any{"visibility": "when_set"}, } createdField, resp, err := client.CreateCPAField(context.Background(), field) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotZero(t, createdField.ID) require.Equal(t, name, createdField.Name) require.Equal(t, "when_set", createdField.Attrs["visibility"]) t.Run("a websocket event should be fired as part of the field creation", func(t *testing.T) { var wsField model.PropertyField require.Eventually(t, func() bool { select { case event := <-webSocketClient.EventChannel: if event.EventType() == model.WebsocketEventCPAFieldCreated { fieldData, err := json.Marshal(event.GetData()["field"]) require.NoError(t, err) require.NoError(t, json.Unmarshal(fieldData, &wsField)) return true } default: return false } return false }, 5*time.Second, 100*time.Millisecond) require.NotEmpty(t, wsField.ID) require.Equal(t, createdField, &wsField) }) }, "a user with admin permissions should be able to create the field") th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { managedField := &model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsManaged: "admin", "visibility": "when_set", }, } createdManagedField, resp, err := client.CreateCPAField(context.Background(), managedField) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotZero(t, createdManagedField.ID) require.Equal(t, managedField.Name, createdManagedField.Name) require.Equal(t, "admin", createdManagedField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged]) require.Equal(t, "when_set", createdManagedField.Attrs["visibility"]) }, "admin should be able to create a managed field") } func TestListCPAFields(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }) defer th.TearDown() field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: map[string]any{"visibility": "when_set"}, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) t.Run("endpoint should not work if no valid license is present", func(t *testing.T) { fields, resp, err := th.Client.ListCPAFields(context.Background()) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, fields) }) // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("any user should be able to list fields", func(t *testing.T) { fields, resp, err := th.Client.ListCPAFields(context.Background()) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, fields) require.Len(t, fields, 1) require.Equal(t, createdField.ID, fields[0].ID) }) t.Run("the endpoint should only list non deleted fields", func(t *testing.T) { require.Nil(t, th.App.DeleteCPAField(createdField.ID)) fields, resp, err := th.Client.ListCPAFields(context.Background()) CheckOKStatus(t, resp) require.NoError(t, err) require.Empty(t, fields) }) } func TestPatchCPAField(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }) defer th.TearDown() th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { patch := &model.PropertyFieldPatch{Name: model.NewPointer(model.NewId())} patchedField, resp, err := client.PatchCPAField(context.Background(), model.NewId(), patch) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, patchedField) }, "endpoint should not work if no valid license is present") // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("a user without admin permissions should not be able to patch a field", func(t *testing.T) { field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) patch := &model.PropertyFieldPatch{Name: model.NewPointer(model.NewId())} _, resp, err := th.Client.PatchCPAField(context.Background(), createdField.ID, patch) CheckForbiddenStatus(t, resp) require.Error(t, err) }) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { webSocketClient := th.CreateConnectedWebSocketClient(t) field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) newName := model.NewId() patch := &model.PropertyFieldPatch{Name: model.NewPointer(fmt.Sprintf(" %s \t ", newName))} // name should be sanitized patchedField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, patch) CheckOKStatus(t, resp) require.NoError(t, err) require.Equal(t, newName, patchedField.Name) t.Run("a websocket event should be fired as part of the field patch", func(t *testing.T) { var wsField model.PropertyField require.Eventually(t, func() bool { select { case event := <-webSocketClient.EventChannel: if event.EventType() == model.WebsocketEventCPAFieldUpdated { fieldData, err := json.Marshal(event.GetData()["field"]) require.NoError(t, err) require.NoError(t, json.Unmarshal(fieldData, &wsField)) return true } default: return false } return false }, 5*time.Second, 100*time.Millisecond) require.NotEmpty(t, wsField.ID) require.Equal(t, patchedField, &wsField) }) t.Run("sanitization should remove options and sync details when necessary", func(t *testing.T) { // Create a select field with options optionID1 := model.NewId() optionID2 := model.NewId() selectField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeSelect, Attrs: model.StringInterface{ "options": []map[string]any{ {"id": optionID1, "name": "Option 1", "color": "#FF0000"}, {"id": optionID2, "name": "Option 2", "color": "#00FF00"}, }, }, }) require.NoError(t, err) createdField, _, err := client.CreateCPAField(context.Background(), selectField.ToPropertyField()) require.NoError(t, err) require.NotNil(t, createdField) // Verify options were created options, ok := createdField.Attrs["options"] require.True(t, ok) require.NotNil(t, options) // Patch to change type to text with LDAP attribute // Options should be automatically removed even though we don't explicitly remove them ldapAttr := "user_attribute" textPatch := &model.PropertyFieldPatch{ Type: model.NewPointer(model.PropertyFieldTypeText), Attrs: &model.StringInterface{"ldap": ldapAttr}, } patchedTextField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, textPatch) CheckOKStatus(t, resp) require.NoError(t, err) require.Equal(t, model.PropertyFieldTypeText, patchedTextField.Type) // Verify options were removed options = patchedTextField.Attrs["options"] require.Empty(t, options) // Verify LDAP attribute was set ldap, ok := patchedTextField.Attrs["ldap"] require.True(t, ok) require.Equal(t, ldapAttr, ldap) // Now patch to change type to date // LDAP attribute should be automatically removed even though we don't explicitly remove it datePatch := &model.PropertyFieldPatch{ Type: model.NewPointer(model.PropertyFieldTypeDate), } patchedDateField, resp, err := client.PatchCPAField(context.Background(), patchedTextField.ID, datePatch) CheckOKStatus(t, resp) require.NoError(t, err) require.Equal(t, model.PropertyFieldTypeDate, patchedDateField.Type) // Verify LDAP attribute was removed ldap = patchedDateField.Attrs["ldap"] require.Empty(t, ldap) }) }, "a user with admin permissions should be able to patch the field") th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { // Create a regular field first field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) // Verify field is not isManaged initially require.Empty(t, createdField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged]) // Patch to make it managed managedPatch := &model.PropertyFieldPatch{ Attrs: &model.StringInterface{ model.CustomProfileAttributesPropertyAttrsManaged: "admin", }, } patchedManagedField, resp, err := client.PatchCPAField(context.Background(), createdField.ID, managedPatch) CheckOKStatus(t, resp) require.NoError(t, err) require.Equal(t, "admin", patchedManagedField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged]) // Patch to remove managed attribute unManagedPatch := &model.PropertyFieldPatch{ Attrs: &model.StringInterface{ model.CustomProfileAttributesPropertyAttrsManaged: "", }, } patchedUnmanagedField, resp, err := client.PatchCPAField(context.Background(), patchedManagedField.ID, unManagedPatch) CheckOKStatus(t, resp) require.NoError(t, err) // Verify managed attribute is removed or empty require.Empty(t, patchedUnmanagedField.Attrs[model.CustomProfileAttributesPropertyAttrsManaged]) }, "admin should be able to toggle managed attribute on existing field") } func TestDeleteCPAField(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }) defer th.TearDown() th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { resp, err := client.DeleteCPAField(context.Background(), model.NewId()) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") }, "endpoint should not work if no valid license is present") // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("a user without admin permissions should not be able to delete a field", func(t *testing.T) { field := &model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, } createdField, _, err := th.SystemAdminClient.CreateCPAField(context.Background(), field) require.NoError(t, err) require.NotNil(t, createdField) resp, err := th.Client.DeleteCPAField(context.Background(), createdField.ID) CheckForbiddenStatus(t, resp) require.Error(t, err) }) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { webSocketClient := th.CreateConnectedWebSocketClient(t) field := &model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, } createdField, _, err := th.SystemAdminClient.CreateCPAField(context.Background(), field) require.NoError(t, err) require.NotNil(t, createdField) require.Zero(t, createdField.DeleteAt) resp, err := client.DeleteCPAField(context.Background(), createdField.ID) CheckOKStatus(t, resp) require.NoError(t, err) deletedField, appErr := th.App.GetCPAField(createdField.ID) require.Nil(t, appErr) require.NotZero(t, deletedField.DeleteAt) t.Run("a websocket event should be fired as part of the field deletion", func(t *testing.T) { var fieldID string require.Eventually(t, func() bool { select { case event := <-webSocketClient.EventChannel: if event.EventType() == model.WebsocketEventCPAFieldDeleted { var ok bool fieldID, ok = event.GetData()["field_id"].(string) require.True(t, ok) return true } default: return false } return false }, 5*time.Second, 100*time.Millisecond) require.Equal(t, createdField.ID, fieldID) }) }, "a user with admin permissions should be able to delete the field") } func TestListCPAValues(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }).InitBasic() defer th.TearDown() th.RemovePermissionFromRole(model.PermissionViewMembers.Id, model.SystemUserRoleId) defer th.AddPermissionToRole(model.PermissionViewMembers.Id, model.SystemUserRoleId) field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) _, appErr = th.App.PatchCPAValue(th.BasicUser.Id, createdField.ID, json.RawMessage(`"Field Value"`), true) require.Nil(t, appErr) t.Run("endpoint should not work if no valid license is present", func(t *testing.T) { values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, values) }) // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) // login with Client2 from this point on th.LoginBasic2() t.Run("any team member should be able to list values", func(t *testing.T) { values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) require.Len(t, values, 1) }) t.Run("should handle array values correctly", func(t *testing.T) { optionID1 := model.NewId() optionID2 := model.NewId() arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeMultiselect, Attrs: model.StringInterface{ "options": []map[string]any{ {"id": optionID1, "name": "option1"}, {"id": optionID2, "name": "option2"}, }, }, }) require.NoError(t, err) createdArrayField, appErr := th.App.CreateCPAField(arrayField) require.Nil(t, appErr) require.NotNil(t, createdArrayField) _, appErr = th.App.PatchCPAValue(th.BasicUser.Id, createdArrayField.ID, json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionID1, optionID2)), true) require.Nil(t, appErr) values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) var arrayValues []string require.NoError(t, json.Unmarshal(values[createdArrayField.ID], &arrayValues)) require.ElementsMatch(t, []string{optionID1, optionID2}, arrayValues) }) t.Run("non team member should NOT be able to list values", func(t *testing.T) { resp, err := th.SystemAdminClient.RemoveTeamMember(context.Background(), th.BasicTeam.Id, th.BasicUser2.Id) CheckOKStatus(t, resp) require.NoError(t, err) _, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckForbiddenStatus(t, resp) require.Error(t, err) }) } func TestPatchCPAValues(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }).InitBasic() defer th.TearDown() field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) t.Run("endpoint should not work if no valid license is present", func(t *testing.T) { values := map[string]json.RawMessage{createdField.ID: json.RawMessage(`"Field Value"`)} patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, patchedValues) }) // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("any team member should be able to create their own values", func(t *testing.T) { webSocketClient := th.CreateConnectedWebSocketClient(t) values := map[string]json.RawMessage{} value := "Field Value" values[createdField.ID] = json.RawMessage(fmt.Sprintf(`" %s "`, value)) // value should be sanitized patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) require.Len(t, patchedValues, 1) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) require.Len(t, values, 1) actualValue = "" require.NoError(t, json.Unmarshal(values[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) t.Run("a websocket event should be fired as part of the value changes", func(t *testing.T) { var wsValues map[string]json.RawMessage require.Eventually(t, func() bool { select { case event := <-webSocketClient.EventChannel: if event.EventType() == model.WebsocketEventCPAValuesUpdated { valuesData, err := json.Marshal(event.GetData()["values"]) require.NoError(t, err) require.NoError(t, json.Unmarshal(valuesData, &wsValues)) return true } default: return false } return false }, 5*time.Second, 100*time.Millisecond) require.NotEmpty(t, wsValues) require.Equal(t, patchedValues, wsValues) }) }) t.Run("any team member should be able to patch their own values", func(t *testing.T) { values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) require.Len(t, values, 1) value := "Updated Field Value" values[createdField.ID] = json.RawMessage(fmt.Sprintf(`" %s \t"`, value)) // value should be sanitized patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) actualValue = "" require.NoError(t, json.Unmarshal(values[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) }) t.Run("should handle array values correctly", func(t *testing.T) { optionsID := []string{model.NewId(), model.NewId(), model.NewId(), model.NewId()} arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeMultiselect, Attrs: model.StringInterface{ "options": []map[string]any{ {"id": optionsID[0], "name": "option1"}, {"id": optionsID[1], "name": "option2"}, {"id": optionsID[2], "name": "option3"}, {"id": optionsID[3], "name": "option4"}, }, }, }) require.NoError(t, err) createdArrayField, appErr := th.App.CreateCPAField(arrayField) require.Nil(t, appErr) require.NotNil(t, createdArrayField) values := map[string]json.RawMessage{ createdArrayField.ID: json.RawMessage(fmt.Sprintf(`["%s", "%s", "%s"]`, optionsID[0], optionsID[1], optionsID[2])), } patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValues []string require.NoError(t, json.Unmarshal(patchedValues[createdArrayField.ID], &actualValues)) require.Equal(t, optionsID[:3], actualValues) // Test updating array values values[createdArrayField.ID] = json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionsID[2], optionsID[3])) patchedValues, resp, err = th.Client.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) actualValues = nil require.NoError(t, json.Unmarshal(patchedValues[createdArrayField.ID], &actualValues)) require.Equal(t, optionsID[2:4], actualValues) }) t.Run("should fail if any of the values belongs to a field that is LDAP/SAML synced", func(t *testing.T) { // Create a field with LDAP attribute ldapField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attr", }, }) require.NoError(t, err) createdLDAPField, appErr := th.App.CreateCPAField(ldapField) require.Nil(t, appErr) require.NotNil(t, createdLDAPField) // Create a field with SAML attribute samlField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsSAML: "saml_attr", }, }) require.NoError(t, err) createdSAMLField, appErr := th.App.CreateCPAField(samlField) require.Nil(t, appErr) require.NotNil(t, createdSAMLField) // Test LDAP field values := map[string]json.RawMessage{ createdLDAPField.ID: json.RawMessage(`"LDAP Value"`), } _, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") // Test SAML field values = map[string]json.RawMessage{ createdSAMLField.ID: json.RawMessage(`"SAML Value"`), } _, resp, err = th.Client.PatchCPAValues(context.Background(), values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") // Test multiple fields with one being LDAP synced values = map[string]json.RawMessage{ createdField.ID: json.RawMessage(`"Regular Value"`), createdLDAPField.ID: json.RawMessage(`"LDAP Value"`), } _, resp, err = th.Client.PatchCPAValues(context.Background(), values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") }) t.Run("an invalid patch should be rejected", func(t *testing.T) { field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) // Create a value that's too long (over 64 characters) tooLongValue := strings.Repeat("a", model.CPAValueTypeTextMaxLength+1) values := map[string]json.RawMessage{ createdField.ID: json.RawMessage(fmt.Sprintf(`"%s"`, tooLongValue)), } _, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckBadRequestStatus(t, resp) require.Error(t, err) require.Contains(t, err.Error(), "Failed to validate property value") }) t.Run("admin-managed fields", func(t *testing.T) { // Create a managed field (only admins can create fields) managedField := &model.PropertyField{ Name: "Managed Field", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsManaged: "admin", }, } createdManagedField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), managedField) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotNil(t, createdManagedField) // Create a non-managed field for comparison regularField := &model.PropertyField{ Name: "Regular Field", Type: model.PropertyFieldTypeText, } createdRegularField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), regularField) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotNil(t, createdRegularField) t.Run("regular user cannot update managed field", func(t *testing.T) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Managed Value"`), } _, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error") }) t.Run("regular user can update non-managed field", func(t *testing.T) { values := map[string]json.RawMessage{ createdRegularField.ID: json.RawMessage(`"Regular Value"`), } patchedValues, resp, err := th.Client.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdRegularField.ID], &actualValue)) require.Equal(t, "Regular Value", actualValue) }) t.Run("system admin can update managed field", func(t *testing.T) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Admin Updated Value"`), } patchedValues, resp, err := th.SystemAdminClient.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdManagedField.ID], &actualValue)) require.Equal(t, "Admin Updated Value", actualValue) }) t.Run("batch update with managed fields fails for regular user", func(t *testing.T) { // First set some initial values to ensure we can verify they don't change // Set initial values for both fields using th.App (admins can set managed field values) _, appErr := th.App.PatchCPAValue(th.BasicUser.Id, createdRegularField.ID, json.RawMessage(`"Initial Regular Value"`), false) require.Nil(t, appErr) _, appErr = th.App.PatchCPAValue(th.BasicUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Managed Value"`), true) require.Nil(t, appErr) // Try to batch update both managed and regular fields - this should fail attemptedValues := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Managed Batch Value"`), createdRegularField.ID: json.RawMessage(`"Regular Batch Value"`), } _, resp, err := th.Client.PatchCPAValues(context.Background(), attemptedValues) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error") // Verify that no values were updated when the batch operation failed currentValues, appErr := th.App.ListCPAValues(th.BasicUser.Id) require.Nil(t, appErr) // Check that values remain unchanged - both fields should retain their initial values regularFieldHasOriginalValue := false managedFieldHasOriginalValue := false for _, value := range currentValues { if value.FieldID == createdManagedField.ID { var currentValue string require.NoError(t, json.Unmarshal(value.Value, ¤tValue)) if currentValue == "Initial Managed Value" { managedFieldHasOriginalValue = true } // Verify it's not the attempted update value require.NotEqual(t, "Managed Batch Value", currentValue, "Managed field should not have been updated in failed batch operation") } if value.FieldID == createdRegularField.ID { var currentValue string require.NoError(t, json.Unmarshal(value.Value, ¤tValue)) if currentValue == "Initial Regular Value" { regularFieldHasOriginalValue = true } // Verify it's not the attempted update value require.NotEqual(t, "Regular Batch Value", currentValue, "Regular field should not have been updated in failed batch operation") } } // Both fields should retain their original values after the failed batch operation require.True(t, regularFieldHasOriginalValue, "Regular field should retain its original value") require.True(t, managedFieldHasOriginalValue, "Managed field should retain its original value") }) t.Run("batch update with managed fields succeeds for admin", func(t *testing.T) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Admin Managed Batch"`), createdRegularField.ID: json.RawMessage(`"Admin Regular Batch"`), } patchedValues, resp, err := th.SystemAdminClient.PatchCPAValues(context.Background(), values) CheckOKStatus(t, resp) require.NoError(t, err) require.Len(t, patchedValues, 2) var managedValue, regularValue string require.NoError(t, json.Unmarshal(patchedValues[createdManagedField.ID], &managedValue)) require.NoError(t, json.Unmarshal(patchedValues[createdRegularField.ID], ®ularValue)) require.Equal(t, "Admin Managed Batch", managedValue) require.Equal(t, "Admin Regular Batch", regularValue) }) }) } func TestPatchCPAValuesForUser(t *testing.T) { mainHelper.Parallel(t) th := SetupConfig(t, func(cfg *model.Config) { cfg.FeatureFlags.CustomProfileAttributes = true }).InitBasic() defer th.TearDown() field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) t.Run("endpoint should not work if no valid license is present", func(t *testing.T) { values := map[string]json.RawMessage{createdField.ID: json.RawMessage(`"Field Value"`)} patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.custom_profile_attributes.license_error") require.Empty(t, patchedValues) }) // add a valid license th.App.Srv().SetLicense(model.NewTestLicenseSKU(model.LicenseShortSkuEnterprise)) t.Run("any team member should be able to create their own values", func(t *testing.T) { webSocketClient := th.CreateConnectedWebSocketClient(t) values := map[string]json.RawMessage{} value := "Field Value" values[createdField.ID] = json.RawMessage(fmt.Sprintf(`" %s "`, value)) // value should be sanitized patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) require.Len(t, patchedValues, 1) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) require.Len(t, values, 1) actualValue = "" require.NoError(t, json.Unmarshal(values[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) t.Run("a websocket event should be fired as part of the value changes", func(t *testing.T) { var wsValues map[string]json.RawMessage require.Eventually(t, func() bool { select { case event := <-webSocketClient.EventChannel: if event.EventType() == model.WebsocketEventCPAValuesUpdated { valuesData, err := json.Marshal(event.GetData()["values"]) require.NoError(t, err) require.NoError(t, json.Unmarshal(valuesData, &wsValues)) return true } default: return false } return false }, 5*time.Second, 100*time.Millisecond) require.NotEmpty(t, wsValues) require.Equal(t, patchedValues, wsValues) }) }) t.Run("any team member should be able to patch their own values", func(t *testing.T) { values, resp, err := th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, values) require.Len(t, values, 1) value := "Updated Field Value" values[createdField.ID] = json.RawMessage(fmt.Sprintf(`" %s \t"`, value)) // value should be sanitized patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) values, resp, err = th.Client.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) actualValue = "" require.NoError(t, json.Unmarshal(values[createdField.ID], &actualValue)) require.Equal(t, value, actualValue) }) t.Run("should handle array values correctly", func(t *testing.T) { optionsID := []string{model.NewId(), model.NewId(), model.NewId(), model.NewId()} arrayField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeMultiselect, Attrs: model.StringInterface{ "options": []map[string]any{ {"id": optionsID[0], "name": "option1"}, {"id": optionsID[1], "name": "option2"}, {"id": optionsID[2], "name": "option3"}, {"id": optionsID[3], "name": "option4"}, }, }, }) require.NoError(t, err) createdArrayField, appErr := th.App.CreateCPAField(arrayField) require.Nil(t, appErr) require.NotNil(t, createdArrayField) values := map[string]json.RawMessage{ createdArrayField.ID: json.RawMessage(fmt.Sprintf(`["%s", "%s", "%s"]`, optionsID[0], optionsID[1], optionsID[2])), } patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValues []string require.NoError(t, json.Unmarshal(patchedValues[createdArrayField.ID], &actualValues)) require.Equal(t, optionsID[:3], actualValues) // Test updating array values values[createdArrayField.ID] = json.RawMessage(fmt.Sprintf(`["%s", "%s"]`, optionsID[2], optionsID[3])) patchedValues, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) actualValues = nil require.NoError(t, json.Unmarshal(patchedValues[createdArrayField.ID], &actualValues)) require.Equal(t, optionsID[2:4], actualValues) }) t.Run("should fail if any of the values belongs to a field that is LDAP/SAML synced", func(t *testing.T) { // Create a field with LDAP attribute ldapField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsLDAP: "ldap_attr", }, }) require.NoError(t, err) createdLDAPField, appErr := th.App.CreateCPAField(ldapField) require.Nil(t, appErr) require.NotNil(t, createdLDAPField) // Create a field with SAML attribute samlField, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsSAML: "saml_attr", }, }) require.NoError(t, err) createdSAMLField, appErr := th.App.CreateCPAField(samlField) require.Nil(t, appErr) require.NotNil(t, createdSAMLField) // Test LDAP field values := map[string]json.RawMessage{ createdLDAPField.ID: json.RawMessage(`"LDAP Value"`), } _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") // Test SAML field values = map[string]json.RawMessage{ createdSAMLField.ID: json.RawMessage(`"SAML Value"`), } _, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") // Test multiple fields with one being LDAP synced values = map[string]json.RawMessage{ createdField.ID: json.RawMessage(`"Regular Value"`), createdLDAPField.ID: json.RawMessage(`"LDAP Value"`), } _, resp, err = th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckBadRequestStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_synced.app_error") }) t.Run("an invalid patch should be rejected", func(t *testing.T) { field, err := model.NewCPAFieldFromPropertyField(&model.PropertyField{ Name: model.NewId(), Type: model.PropertyFieldTypeText, }) require.NoError(t, err) createdField, appErr := th.App.CreateCPAField(field) require.Nil(t, appErr) require.NotNil(t, createdField) // Create a value that's too long (over 64 characters) tooLongValue := strings.Repeat("a", model.CPAValueTypeTextMaxLength+1) values := map[string]json.RawMessage{ createdField.ID: json.RawMessage(fmt.Sprintf(`"%s"`, tooLongValue)), } _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckBadRequestStatus(t, resp) require.Error(t, err) require.Contains(t, err.Error(), "Failed to validate property value") }) t.Run("admin-managed fields", func(t *testing.T) { // Create a managed field (only admins can create fields) managedField := &model.PropertyField{ Name: "Managed Field", Type: model.PropertyFieldTypeText, Attrs: model.StringInterface{ model.CustomProfileAttributesPropertyAttrsManaged: "admin", }, } createdManagedField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), managedField) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotNil(t, createdManagedField) // Create a non-managed field for comparison regularField := &model.PropertyField{ Name: "Regular Field", Type: model.PropertyFieldTypeText, } createdRegularField, resp, err := th.SystemAdminClient.CreateCPAField(context.Background(), regularField) CheckCreatedStatus(t, resp) require.NoError(t, err) require.NotNil(t, createdRegularField) t.Run("regular user cannot update managed field", func(t *testing.T) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Managed Value"`), } _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error") }) t.Run("regular user can update non-managed field", func(t *testing.T) { values := map[string]json.RawMessage{ createdRegularField.ID: json.RawMessage(`"Regular Value"`), } patchedValues, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdRegularField.ID], &actualValue)) require.Equal(t, "Regular Value", actualValue) }) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { // Set initial value through the app layer that we will be replacing during the test _, appErr := th.App.PatchCPAValue(th.SystemAdminUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Admin Value"`), true) require.Nil(t, appErr) values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Admin Updated Value"`), } patchedValues, resp, err := client.PatchCPAValuesForUser(context.Background(), th.SystemAdminUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdManagedField.ID], &actualValue)) require.Equal(t, "Admin Updated Value", actualValue) }, "system admin can update managed field") th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Admin Updated Managed Value For Other User"`), } patchedValues, resp, err := th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, patchedValues) var actualValue string require.NoError(t, json.Unmarshal(patchedValues[createdManagedField.ID], &actualValue)) require.Equal(t, "Admin Updated Managed Value For Other User", actualValue) // Verify the value was actually set for the target user userValues, resp, err := th.SystemAdminClient.ListCPAValues(context.Background(), th.BasicUser.Id) CheckOKStatus(t, resp) require.NoError(t, err) require.NotEmpty(t, userValues) var storedValue string require.NoError(t, json.Unmarshal(userValues[createdManagedField.ID], &storedValue)) require.Equal(t, "Admin Updated Managed Value For Other User", storedValue) }, "system admin can update managed field values for other users") t.Run("a user should not be able to update other user's field values", func(t *testing.T) { values := map[string]json.RawMessage{ createdRegularField.ID: json.RawMessage(`"Attempted Value For Other User"`), } // th.Client (BasicUser) trying to update th.BasicUser2's values should fail _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser2.Id, values) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "api.context.permissions.app_error") }) t.Run("batch update with managed fields fails for regular user", func(t *testing.T) { // First set some initial values to ensure we can verify they don't change // Set initial values for both fields using th.App (admins can set managed field values) _, appErr := th.App.PatchCPAValue(th.BasicUser.Id, createdRegularField.ID, json.RawMessage(`"Initial Regular Value"`), false) require.Nil(t, appErr) _, appErr = th.App.PatchCPAValue(th.BasicUser.Id, createdManagedField.ID, json.RawMessage(`"Initial Managed Value"`), true) require.Nil(t, appErr) // Try to batch update both managed and regular fields - this should fail attemptedValues := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Managed Batch Value"`), createdRegularField.ID: json.RawMessage(`"Regular Batch Value"`), } _, resp, err := th.Client.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, attemptedValues) CheckForbiddenStatus(t, resp) require.Error(t, err) CheckErrorID(t, err, "app.custom_profile_attributes.property_field_is_managed.app_error") // Verify that no values were updated when the batch operation failed currentValues, appErr := th.App.ListCPAValues(th.BasicUser.Id) require.Nil(t, appErr) // Check that values remain unchanged - both fields should retain their initial values regularFieldHasOriginalValue := false managedFieldHasOriginalValue := false for _, value := range currentValues { if value.FieldID == createdManagedField.ID { var currentValue string require.NoError(t, json.Unmarshal(value.Value, ¤tValue)) if currentValue == "Initial Managed Value" { managedFieldHasOriginalValue = true } // Verify it's not the attempted update value require.NotEqual(t, "Managed Batch Value", currentValue, "Managed field should not have been updated in failed batch operation") } if value.FieldID == createdRegularField.ID { var currentValue string require.NoError(t, json.Unmarshal(value.Value, ¤tValue)) if currentValue == "Initial Regular Value" { regularFieldHasOriginalValue = true } // Verify it's not the attempted update value require.NotEqual(t, "Regular Batch Value", currentValue, "Regular field should not have been updated in failed batch operation") } } // Both fields should retain their original values after the failed batch operation require.True(t, regularFieldHasOriginalValue, "Regular field should retain its original value") require.True(t, managedFieldHasOriginalValue, "Managed field should retain its original value") }) th.TestForSystemAdminAndLocal(t, func(t *testing.T, client *model.Client4) { values := map[string]json.RawMessage{ createdManagedField.ID: json.RawMessage(`"Admin Managed Batch"`), createdRegularField.ID: json.RawMessage(`"Admin Regular Batch"`), } patchedValues, resp, err := th.SystemAdminClient.PatchCPAValuesForUser(context.Background(), th.BasicUser.Id, values) CheckOKStatus(t, resp) require.NoError(t, err) require.Len(t, patchedValues, 2) var managedValue, regularValue string require.NoError(t, json.Unmarshal(patchedValues[createdManagedField.ID], &managedValue)) require.NoError(t, json.Unmarshal(patchedValues[createdRegularField.ID], ®ularValue)) require.Equal(t, "Admin Managed Batch", managedValue) require.Equal(t, "Admin Regular Batch", regularValue) }, "batch update with managed fields succeeds for admin") }) }