Repository: owncloud/ocis
Pull request #5773 (2023-03-09 03:56:19)

[tests-only][full-ci]Seperate test steps for positive and negative cases
Forward porting: https://github.com/owncloud/ocis/pull/5733 https://github.com/owncloud/ocis/pull/5745

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5773)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5773&metric=coverage&view=list) No Coverage information  
[![No Duplication information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo-16px.png 'No Duplication information')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5773&metric=duplicated_lines_density&view=list) No Duplication information


ownclouders: E2E tests failed: https://drone.owncloud.com/owncloud/ocis/20087/84/1
<details><summary>:boom: To see the trace, please open the link in the console ...</summary>

<p>


#### npx playwright show-trace https://cache.owncloud.com/public/owncloud/ocis/20087/tracing/user-login-can-be-managed-in-the-admin-settings-admin-2023-3-9-04-06-38.zip 


</p></details>

<!-- id: pr-5773 -->

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=owncloud_ocis&pullRequest=5773)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=owncloud_ocis&pullRequest=5773&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=owncloud_ocis&pullRequest=5773&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5773&metric=coverage&view=list) No Coverage information  
[![No Duplication information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo-16px.png 'No Duplication information')](https://sonarcloud.io/component_measures?id=owncloud_ocis&pullRequest=5773&metric=duplicated_lines_density&view=list) No Duplication information



Patch:
diff --git a/go.mod b/go.mod
index 17cc762d4f7..5128ac0a5c1 100644
--- a/go.mod
+++ b/go.mod
@@ -78,8 +78,8 @@ require (
 	go.etcd.io/etcd/client/v3 v3.5.6
 	go.opencensus.io v0.23.0
 	go.opentelemetry.io/otel v1.14.0
-	go.opentelemetry.io/otel/exporters/jaeger v1.11.2
-	go.opentelemetry.io/otel/sdk v1.13.0
+	go.opentelemetry.io/otel/exporters/jaeger v1.14.0
+	go.opentelemetry.io/otel/sdk v1.14.0
 	go.opentelemetry.io/otel/trace v1.14.0
 	golang.org/x/crypto v0.7.0
 	golang.org/x/exp v0.0.0-20221026004748-78e5e7837ae6
diff --git a/go.sum b/go.sum
index e8fed31328c..853be9e1c3e 100644
--- a/go.sum
+++ b/go.sum
@@ -1326,10 +1326,10 @@ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.3
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.36.4/go.mod h1:05eWWy6ZWzmpeImD3UowLTB3VjDMU1yxQ+ENuVWDM3c=
 go.opentelemetry.io/otel v1.14.0 h1:/79Huy8wbf5DnIPhemGB+zEPVwnN6fuQybr/SRXa6hM=
 go.opentelemetry.io/otel v1.14.0/go.mod h1:o4buv+dJzx8rohcUeRmWUZhqupFvzWis188WlggnNeU=
-go.opentelemetry.io/otel/exporters/jaeger v1.11.2 h1:ES8/j2+aB+3/BUw51ioxa50V9btN1eew/2J7N7n1tsE=
-go.opentelemetry.io/otel/exporters/jaeger v1.11.2/go.mod h1:nwcF/DK4Hk0auZ/a5vw20uMsaJSXbzeeimhN5f9d0Lc=
-go.opentelemetry.io/otel/sdk v1.13.0 h1:BHib5g8MvdqS65yo2vV1s6Le42Hm6rrw08qU6yz5JaM=
-go.opentelemetry.io/otel/sdk v1.13.0/go.mod h1:YLKPx5+6Vx/o1TCUYYs+bpymtkmazOMT6zoRrC7AQ7I=
+go.opentelemetry.io/otel/exporters/jaeger v1.14.0 h1:CjbUNd4iN2hHmWekmOqZ+zSCU+dzZppG8XsV+A3oc8Q=
+go.opentelemetry.io/otel/exporters/jaeger v1.14.0/go.mod h1:4Ay9kk5vELRrbg5z4cpP9EtmQRFap2Wb0woPG4lujZA=
+go.opentelemetry.io/otel/sdk v1.14.0 h1:PDCppFRDq8A1jL9v6KMI6dYesaq+DFcDZvjsoGvxGzY=
+go.opentelemetry.io/otel/sdk v1.14.0/go.mod h1:bwIC5TjrNG6QDCHNWvW4HLHtUQ4I+VQDsnjhvyZCALM=
 go.opentelemetry.io/otel/trace v1.14.0 h1:wp2Mmvj41tDsyAJXiWDWpfNsOiIyd38fy85pyKcFq/M=
 go.opentelemetry.io/otel/trace v1.14.0/go.mod h1:8avnQLK+CG77yNLUae4ea2JDQ6iT+gozhnZjy/rw9G8=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
diff --git a/services/graph/pkg/identity/ldap.go b/services/graph/pkg/identity/ldap.go
index 7d1d19e5f27..5266e4f06e7 100644
--- a/services/graph/pkg/identity/ldap.go
+++ b/services/graph/pkg/identity/ldap.go
@@ -258,13 +258,13 @@ func (i *LDAP) UpdateUser(ctx context.Context, nameOrID string, user libregraph.
 	var updateNeeded bool
 
 	// Don't allow updates of the ID
-	if user.Id != nil && *user.Id != "" {
-		if e.GetEqualFoldAttributeValue(i.userAttributeMap.id) != *user.Id {
+	if user.GetId() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.id) != user.GetId() {
 			return nil, errorcode.New(errorcode.NotAllowed, "changing the UserId is not allowed")
 		}
 	}
-	if user.OnPremisesSamAccountName != nil && *user.OnPremisesSamAccountName != "" {
-		if eu := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName); eu != *user.OnPremisesSamAccountName {
+	if user.GetOnPremisesSamAccountName() != "" {
+		if eu := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName); eu != user.GetOnPremisesSamAccountName() {
 			e, err = i.changeUserName(ctx, e.DN, eu, user.GetOnPremisesSamAccountName())
 			if err != nil {
 				return nil, err
@@ -273,19 +273,31 @@ func (i *LDAP) UpdateUser(ctx context.Context, nameOrID string, user libregraph.
 	}
 
 	mr := ldap.ModifyRequest{DN: e.DN}
-	if user.DisplayName != nil && *user.DisplayName != "" {
-		if e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName) != *user.DisplayName {
-			mr.Replace(i.userAttributeMap.displayName, []string{*user.DisplayName})
+	if user.GetDisplayName() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName) != user.GetDisplayName() {
+			mr.Replace(i.userAttributeMap.displayName, []string{user.GetDisplayName()})
 			updateNeeded = true
 		}
 	}
-	if user.Mail != nil && *user.Mail != "" {
-		if e.GetEqualFoldAttributeValue(i.userAttributeMap.mail) != *user.Mail {
-			mr.Replace(i.userAttributeMap.mail, []string{*user.Mail})
+	if user.GetMail() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.mail) != user.GetMail() {
+			mr.Replace(i.userAttributeMap.mail, []string{user.GetMail()})
 			updateNeeded = true
 		}
 	}
-	if user.PasswordProfile != nil && user.PasswordProfile.Password != nil && *user.PasswordProfile.Password != "" {
+	if user.GetSurname() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.surname) != user.GetSurname() {
+			mr.Replace(i.userAttributeMap.surname, []string{user.GetSurname()})
+			updateNeeded = true
+		}
+	}
+	if user.GetGivenName() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.givenName) != user.GetGivenName() {
+			mr.Replace(i.userAttributeMap.givenName, []string{user.GetGivenName()})
+			updateNeeded = true
+		}
+	}
+	if user.PasswordProfile != nil && user.PasswordProfile.GetPassword() != "" {
 		if i.usePwModifyExOp {
 			if err := i.updateUserPassowrd(ctx, e.DN, user.PasswordProfile.GetPassword()); err != nil {
 				return nil, err
@@ -293,7 +305,7 @@ func (i *LDAP) UpdateUser(ctx context.Context, nameOrID string, user libregraph.
 		} else {
 			// password are hashed server side there is no need to check if the new password
 			// is actually different from the old one.
-			mr.Replace("userPassword", []string{*user.PasswordProfile.Password})
+			mr.Replace("userPassword", []string{user.PasswordProfile.GetPassword()})
 			updateNeeded = true
 		}
 	}
diff --git a/services/graph/pkg/identity/ldap_education_user.go b/services/graph/pkg/identity/ldap_education_user.go
index e346bd9897f..a0b32e57dc4 100644
--- a/services/graph/pkg/identity/ldap_education_user.go
+++ b/services/graph/pkg/identity/ldap_education_user.go
@@ -77,7 +77,108 @@ func (i *LDAP) DeleteEducationUser(ctx context.Context, nameOrID string) error {
 
 // UpdateEducationUser applies changes to given education user, identified by username or id
 func (i *LDAP) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
-	return nil, errNotImplemented
+	logger := i.logger.SubloggerWithRequestID(ctx)
+	logger.Debug().Str("backend", "ldap").Msg("UpdateEducationUser")
+	if !i.writeEnabled {
+		return nil, ErrReadOnly
+	}
+	e, err := i.getEducationUserByNameOrID(nameOrID)
+	if err != nil {
+		return nil, err
+	}
+
+	var updateNeeded bool
+
+	// Don't allow updates of the ID
+	if user.GetId() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.id) != user.GetId() {
+			return nil, errorcode.New(errorcode.NotAllowed, "changing the UserId is not allowed")
+		}
+	}
+	if user.GetOnPremisesSamAccountName() != "" {
+		if eu := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName); eu != user.GetOnPremisesSamAccountName() {
+			e, err = i.changeUserName(ctx, e.DN, eu, user.GetOnPremisesSamAccountName())
+			if err != nil {
+				return nil, err
+			}
+			e, err = i.getEducationUserByDN(e.DN)
+			if err != nil {
+				return nil, err
+			}
+		}
+	}
+
+	mr := ldap.ModifyRequest{DN: e.DN}
+	if user.GetDisplayName() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.displayName) != user.GetDisplayName() {
+			mr.Replace(i.userAttributeMap.displayName, []string{user.GetDisplayName()})
+			updateNeeded = true
+		}
+	}
+	if user.GetMail() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.mail) != user.GetMail() {
+			mr.Replace(i.userAttributeMap.mail, []string{user.GetMail()})
+			updateNeeded = true
+		}
+	}
+	if user.GetSurname() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.surname) != user.GetSurname() {
+			mr.Replace(i.userAttributeMap.surname, []string{user.GetSurname()})
+			updateNeeded = true
+		}
+	}
+	if user.GetGivenName() != "" {
+		if e.GetEqualFoldAttributeValue(i.userAttributeMap.givenName) != user.GetGivenName() {
+			mr.Replace(i.userAttributeMap.givenName, []string{user.GetGivenName()})
+			updateNeeded = true
+		}
+	}
+	if user.PasswordProfile != nil && user.PasswordProfile.GetPassword() != "" {
+		if i.usePwModifyExOp {
+			if err := i.updateUserPassowrd(ctx, e.DN, user.PasswordProfile.GetPassword()); err != nil {
+				return nil, err
+			}
+		} else {
+			// password are hashed server side there is no need to check if the new password
+			// is actually different from the old one.
+			mr.Replace("userPassword", []string{user.PasswordProfile.GetPassword()})
+			updateNeeded = true
+		}
+	}
+	if user.GetPrimaryRole() != "" {
+		if e.GetEqualFoldAttributeValue(i.educationConfig.userAttributeMap.primaryRole) != user.GetPrimaryRole() {
+			mr.Replace(i.educationConfig.userAttributeMap.primaryRole, []string{user.GetPrimaryRole()})
+			updateNeeded = true
+		}
+	}
+	if identities, ok := user.GetIdentitiesOk(); ok {
+		attrValues := make([]string, 0, len(identities))
+		for _, identity := range identities {
+			identityStr, err := i.identityToLDAPAttrValue(identity)
+			if err != nil {
+				return nil, err
+			}
+			attrValues = append(attrValues, identityStr)
+		}
+		mr.Replace(i.educationConfig.userAttributeMap.identities, attrValues)
+		updateNeeded = true
+	}
+
+	if updateNeeded {
+		if err := i.conn.Modify(&mr); err != nil {
+			return nil, err
+		}
+	}
+
+	// Read	back user from LDAP to get the generated UUID
+	e, err = i.getEducationUserByDN(e.DN)
+	if err != nil {
+		return nil, err
+	}
+
+	returnUser := i.createEducationUserModelFromLDAP(e)
+
+	return returnUser, nil
 }
 
 // GetEducationUser implements the EducationBackend interface for the LDAP backend.
@@ -189,11 +290,10 @@ func (i *LDAP) educationUserToLDAPAttrValues(user libregraph.EducationUser, attr
 	}
 	if identities, ok := user.GetIdentitiesOk(); ok {
 		for _, identity := range identities {
-			// TODO add support for the "signInType" of objectIdentity
-			if identity.GetIssuer() == "" || identity.GetIssuerAssignedId() == "" {
-				return nil, fmt.Errorf("missing Attribute for objectIdentity")
+			identityStr, err := i.identityToLDAPAttrValue(identity)
+			if err != nil {
+				return nil, err
 			}
-			identityStr := fmt.Sprintf(" $ %s $ %s", identity.GetIssuer(), identity.GetIssuerAssignedId())
 			attrs[i.educationConfig.userAttributeMap.identities] = append(
 				attrs[i.educationConfig.userAttributeMap.identities],
 				identityStr,
@@ -203,6 +303,14 @@ func (i *LDAP) educationUserToLDAPAttrValues(user libregraph.EducationUser, attr
 	attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.userObjectClass)
 	return attrs, nil
 }
+func (i *LDAP) identityToLDAPAttrValue(identity libregraph.ObjectIdentity) (string, error) {
+	// TODO add support for the "signInType" of objectIdentity
+	if identity.GetIssuer() == "" || identity.GetIssuerAssignedId() == "" {
+		return "", fmt.Errorf("missing Attribute for objectIdentity")
+	}
+	identityStr := fmt.Sprintf(" $ %s $ %s", identity.GetIssuer(), identity.GetIssuerAssignedId())
+	return identityStr, nil
+}
 
 func (i *LDAP) educationUserToAddRequest(user libregraph.EducationUser) (*ldap.AddRequest, error) {
 	plainUser := i.educationUserToUser(user)
@@ -234,6 +342,9 @@ func (i *LDAP) getEducationUserAttrTypes() []string {
 		i.userAttributeMap.id,
 		i.userAttributeMap.mail,
 		i.userAttributeMap.userName,
+		i.userAttributeMap.surname,
+		i.userAttributeMap.givenName,
+		i.userAttributeMap.accountEnabled,
 		i.educationConfig.userAttributeMap.identities,
 		i.educationConfig.userAttributeMap.primaryRole,
 		i.educationConfig.memberOfSchoolAttribute,


Test patch:
diff --git a/services/graph/pkg/identity/ldap_education_school_test.go b/services/graph/pkg/identity/ldap_education_school_test.go
index a97d4804d83..ffcbd918a78 100644
--- a/services/graph/pkg/identity/ldap_education_school_test.go
+++ b/services/graph/pkg/identity/ldap_education_school_test.go
@@ -322,7 +322,7 @@ var userByIDSearch1 *ldap.SearchRequest = &ldap.SearchRequest{
 	Scope:      2,
 	SizeLimit:  1,
 	Filter:     "(&(objectClass=ocEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
-	Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+	Attributes: eduUserAttrs,
 	Controls:   []ldap.Control(nil),
 }
 
@@ -331,7 +331,7 @@ var userByIDSearch2 *ldap.SearchRequest = &ldap.SearchRequest{
 	Scope:      2,
 	SizeLimit:  1,
 	Filter:     "(&(objectClass=ocEducationUser)(|(uid=does-not-exist)(entryUUID=does-not-exist)))",
-	Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+	Attributes: eduUserAttrs,
 	Controls:   []ldap.Control(nil),
 }
 
@@ -439,7 +439,7 @@ var usersBySchoolIDSearch *ldap.SearchRequest = &ldap.SearchRequest{
 	Scope:      2,
 	SizeLimit:  0,
 	Filter:     "(&(objectClass=ocEducationUser)(ocMemberOfSchool=abcd-defg))",
-	Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+	Attributes: eduUserAttrs,
 	Controls:   []ldap.Control(nil),
 }
 
diff --git a/services/graph/pkg/identity/ldap_education_user_test.go b/services/graph/pkg/identity/ldap_education_user_test.go
index 960bec73be1..dc4777e4d0e 100644
--- a/services/graph/pkg/identity/ldap_education_user_test.go
+++ b/services/graph/pkg/identity/ldap_education_user_test.go
@@ -11,6 +11,8 @@ import (
 	"github.com/test-go/testify/mock"
 )
 
+var eduUserAttrs = []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname", "userEnabledAttribute", "oCExternalIdentity", "userClass", "ocMemberOfSchool"}
+
 var eduUserEntry = ldap.NewEntry("uid=user,ou=people,dc=test",
 	map[string][]string{
 		"uid":         {"testuser"},
@@ -23,6 +25,18 @@ var eduUserEntry = ldap.NewEntry("uid=user,ou=people,dc=test",
 			"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
 		},
 	})
+var renamedEduUserEntry = ldap.NewEntry("uid=newtestuser,ou=people,dc=test",
+	map[string][]string{
+		"uid":         {"newtestuser"},
+		"displayname": {"Test User"},
+		"mail":        {"user@example"},
+		"entryuuid":   {"abcd-defg"},
+		"userClass":   {"student"},
+		"oCExternalIdentity": {
+			"$ http://idp $ testuser",
+			"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
+		},
+	})
 var eduUserEntryWithSchool = ldap.NewEntry("uid=user,ou=people,dc=test",
 	map[string][]string{
 		"uid":              {"testuser"},
@@ -42,7 +56,7 @@ var sr1 *ldap.SearchRequest = &ldap.SearchRequest{
 	Scope:      2,
 	SizeLimit:  1,
 	Filter:     "(&(objectClass=ocEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
-	Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+	Attributes: eduUserAttrs,
 	Controls:   []ldap.Control(nil),
 }
 var sr2 *ldap.SearchRequest = &ldap.SearchRequest{
@@ -50,7 +64,7 @@ var sr2 *ldap.SearchRequest = &ldap.SearchRequest{
 	Scope:      2,
 	SizeLimit:  1,
 	Filter:     "(&(objectClass=ocEducationUser)(|(uid=xxxx-xxxx)(entryUUID=xxxx-xxxx)))",
-	Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+	Attributes: eduUserAttrs,
 	Controls:   []ldap.Control(nil),
 }
 
@@ -133,7 +147,7 @@ func TestGetEducationUsers(t *testing.T) {
 		Scope:      2,
 		SizeLimit:  0,
 		Filter:     "(objectClass=ocEducationUser)",
-		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "oCExternalIdentity", "userClass", "ocMemberOfSchool"},
+		Attributes: eduUserAttrs,
 		Controls:   []ldap.Control(nil),
 	}
 	lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
@@ -143,3 +157,96 @@ func TestGetEducationUsers(t *testing.T) {
 	lm.AssertNumberOfCalls(t, "Search", 1)
 	assert.Nil(t, err)
 }
+
+func TestUpdateEducationUser(t *testing.T) {
+	lm := &mocks.Client{}
+	b, err := getMockedBackend(lm, eduConfig, &logger)
+	assert.Nil(t, err)
+	userSearchReq := &ldap.SearchRequest{
+		BaseDN:     "ou=people,dc=test",
+		Scope:      2,
+		SizeLimit:  1,
+		Filter:     "(&(objectClass=ocEducationUser)(|(uid=testuser)(entryUUID=testuser)))",
+		Attributes: eduUserAttrs,
+	}
+	userLookupReq := &ldap.SearchRequest{
+		BaseDN:     "uid=newtestuser,ou=people,dc=test",
+		Scope:      0,
+		SizeLimit:  1,
+		Filter:     "(objectClass=inetOrgPerson)",
+		Attributes: []string{"displayname", "entryUUID", "mail", "uid", "sn", "givenname", "userEnabledAttribute"},
+	}
+	eduUserLookupReq := &ldap.SearchRequest{
+		BaseDN:     "uid=newtestuser,ou=people,dc=test",
+		Scope:      0,
+		SizeLimit:  1,
+		Filter:     "(objectClass=ocEducationUser)",
+		Attributes: eduUserAttrs,
+	}
+	groupSearchReq := &ldap.SearchRequest{
+		BaseDN: "ou=groups,dc=test",
+		Scope:  2,
+		Filter: "(&(objectClass=groupOfNames)(member=uid=user,ou=people,dc=test))",
+		Attributes: []string{
+			"cn",
+			"entryUUID",
+		},
+	}
+	lm.On("Search", userLookupReq).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{
+					renamedEduUserEntry,
+				},
+			},
+			nil)
+	lm.On("Search", eduUserLookupReq).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{
+					renamedEduUserEntry,
+				},
+			},
+			nil)
+	lm.On("Search", userSearchReq).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{
+					eduUserEntry,
+				},
+			},
+			nil)
+	lm.On("Search", groupSearchReq).
+		Return(
+			&ldap.SearchResult{
+				Entries: []*ldap.Entry{},
+			},
+			nil)
+	modReq := ldap.ModifyRequest{
+		DN: "uid=newtestuser,ou=people,dc=test",
+		Changes: []ldap.Change{
+			{
+				Operation: ldap.ReplaceAttribute,
+				Modification: ldap.PartialAttribute{
+					Type: "mail",
+					Vals: []string{"new@mail.org"},
+				},
+			},
+		},
+	}
+	modDNReq := ldap.ModifyDNRequest{
+		DN:           "uid=user,ou=people,dc=test",
+		NewRDN:       "uid=newtestuser",
+		DeleteOldRDN: true,
+	}
+	lm.On("ModifyDN", &modDNReq).Return(nil)
+	lm.On("Modify", &modReq).Return(nil)
+	user := libregraph.NewEducationUser()
+	user.SetOnPremisesSamAccountName("newtestuser")
+	user.SetMail("new@mail.org")
+	eduUser, err := b.UpdateEducationUser(context.Background(), "testuser", *user)
+	assert.NotNil(t, eduUser)
+	assert.Nil(t, err)
+	assert.Equal(t, eduUser.GetOnPremisesSamAccountName(), "newtestuser")
+	assert.Equal(t, "abcd-defg", eduUser.GetId())
+}
diff --git a/tests/acceptance/features/apiGraph/addUserToGroup.feature b/tests/acceptance/features/apiGraph/addUserToGroup.feature
index d9a48508272..03e799358dd 100644
--- a/tests/acceptance/features/apiGraph/addUserToGroup.feature
+++ b/tests/acceptance/features/apiGraph/addUserToGroup.feature
@@ -136,7 +136,7 @@ Feature: add users to group
 
 
   Scenario: admin tries to add user to a non-existing group
-    When the administrator tries to add user "Alice" to group "nonexistentgroup" using the Graph API
+    When the administrator tries to add user "Alice" to a nonexistent group using the Graph API
     Then the HTTP status code should be "404"
 
 
@@ -147,7 +147,7 @@ Feature: add users to group
 
 
   Scenario: admin tries to add user to a group without sending the group
-    When the administrator tries to add user "Alice" to group "" using the Graph API
+    When the administrator tries to add user "Alice" to a nonexistent group using the Graph API
     Then the HTTP status code should be "404"
 
 
@@ -175,7 +175,7 @@ Feature: add users to group
       | username |
       | Brian    |
       | Carol    |
-    When the administrator "Alice" tries to add the following users to a non-existing group at once using the Graph API
+    When the administrator "Alice" tries to add the following users to a nonexistent group at once using the Graph API
       | username |
       | Brian    |
       | Carol    |
@@ -185,7 +185,7 @@ Feature: add users to group
   Scenario: admin tries to add multiple non-existing users to a group at once
     Given the administrator has given "Alice" the role "Admin" using the settings api
     And user "Alice" has created a group "grp1" using the Graph API
-    When the administrator "Alice" tries to add the following non-existing users to a group "grp1" at once using the Graph API
+    When the administrator "Alice" tries to add the following nonexistent users to a group "grp1" at once using the Graph API
       | username |
       | Brian    |
       | Carol    |
@@ -198,7 +198,7 @@ Feature: add users to group
       | username |
       | Brian    |
     And user "Alice" has created a group "grp1" using the Graph API
-    When the administrator "Alice" tries to add the following users to a group "grp1" at once using the Graph API
+    When the administrator "Alice" tries to add the following existent and nonexistent users to a group "grp1" at once using the Graph API
       | username |
       | Brian    |
       | Carol    |
diff --git a/tests/acceptance/features/apiGraph/removeUserFromGroup.feature b/tests/acceptance/features/apiGraph/removeUserFromGroup.feature
index f2a51a62f65..554773c8c23 100644
--- a/tests/acceptance/features/apiGraph/removeUserFromGroup.feature
+++ b/tests/acceptance/features/apiGraph/removeUserFromGroup.feature
@@ -152,8 +152,8 @@ Feature: remove a user from a group
       | Alice    | var/../etc       |
 
 
-  Scenario: admin tries to remove a user from a non-existing group
-    When the administrator tries to remove user "Alice" from group "nonexistentgroup" using the Graph API
+  Scenario: admin tries to remove a user from a nonexistent group
+    When the administrator tries to remove user "Alice" from a nonexistent group using the Graph API
     Then the HTTP status code should be "404"
 
 
diff --git a/tests/acceptance/features/bootstrap/FeatureContext.php b/tests/acceptance/features/bootstrap/FeatureContext.php
index 532047a70ac..95c793b84fc 100644
--- a/tests/acceptance/features/bootstrap/FeatureContext.php
+++ b/tests/acceptance/features/bootstrap/FeatureContext.php
@@ -4600,7 +4600,7 @@ class FeatureContext extends BehatVariablesContext {
 	 * @return string
 	 * @throws Exception|GuzzleException
 	 */
-	public function getGroupIdByGroupName(string $groupName): string {
+	public function getGroupIdByGroupName(string $groupName):string {
 		$response = GraphHelper::getGroup(
 			$this->getBaseUrl(),
 			$this->getStepLineRef(),
diff --git a/tests/acceptance/features/bootstrap/GraphContext.php b/tests/acceptance/features/bootstrap/GraphContext.php
index 1c552f0a1d6..9cb5fa8025e 100644
--- a/tests/acceptance/features/bootstrap/GraphContext.php
+++ b/tests/acceptance/features/bootstrap/GraphContext.php
@@ -241,12 +241,8 @@ class GraphContext implements Context {
 	 */
 	public function adminHasRetrievedUserUsingTheGraphApi(string $user): void {
 		$user = $this->featureContext->getActualUsername($user);
-		try {
-			$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
-			$userId = $userId ?? $user;
-		} catch (Exception $e) {
-			$userId = $user;
-		}
+		$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
+		$userId = $userId ? $userId : $user;
 		$result = GraphHelper::getUser(
 			$this->featureContext->getBaseUrl(),
 			$this->featureContext->getStepLineRef(),
@@ -816,17 +812,8 @@ class GraphContext implements Context {
 	 */
 	public function addUserToGroup(string $group, string $user, ?string $byUser = null): ResponseInterface {
 		$credentials = $this->getAdminOrUserCredentials($byUser);
-		try {
-			$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
-		} catch (Exception $e) {
-			$groupId = WebDavHelper::generateUUIDv4();
-		}
-		try {
-			$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
-		} catch (Exception $e) {
-			$userId = WebDavHelper::generateUUIDv4();
-		}
-
+		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
+		$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
 		return GraphHelper::addUserToGroup(
 			$this->featureContext->getBaseUrl(),
 			$this->featureContext->getStepLineRef(),
@@ -888,6 +875,33 @@ class GraphContext implements Context {
 		$this->featureContext->setResponse($this->addUserToGroup($group, $user));
 	}
 
+	/**
+	 * @When the administrator tries to add user :user to a nonexistent group using the Graph API
+	 * @When the user :byUser tries to add user :user to a nonexistent group using the Graph API
+	 *
+	 * @param string $user
+	 * @param string|null $byUser
+	 *
+	 * @return void
+	 *
+	 * @throws GuzzleException | Exception
+	 */
+	public function theAdministratorTriesToAddUserToNonExistentGroupUsingTheGraphAPI(string $user, ?string $byUser = null): void {
+		$credentials = $this->getAdminOrUserCredentials($byUser);
+		$groupId = WebDavHelper::generateUUIDv4();
+		$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
+		$this->featureContext->setResponse(
+			GraphHelper::addUserToGroup(
+				$this->featureContext->getBaseUrl(),
+				$this->featureContext->getStepLineRef(),
+				$credentials['username'],
+				$credentials['password'],
+				$userId,
+				$groupId
+			)
+		);
+	}
+
 	/**
 	 * @When user :user tries to add himself/herself to group :group using the Graph API
 	 *
@@ -1297,18 +1311,27 @@ class GraphContext implements Context {
 	 * @param string|null $byUser
 	 *
 	 * @return void
+	 * @throws Exception | GuzzleException
 	 */
 	public function theUserTriesToRemoveAnotherUserFromGroupUsingTheGraphAPI(string $user, string $group, ?string $byUser = null): void {
-		try {
-			$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
-		} catch (Exception $e) {
-			$groupId = WebDavHelper::generateUUIDv4();
-		}
-		try {
-			$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
-		} catch (Exception $e) {
-			$userId = WebDavHelper::generateUUIDv4();
-		}
+		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
+		$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
+		$this->featureContext->setResponse($this->removeUserFromGroup($groupId, $userId, $byUser));
+	}
+
+	/**
+	 * @When the administrator tries to remove user :user from a nonexistent group using the Graph API
+	 * @When user :byUser tries to remove user :user from a nonexistent group using the Graph API
+	 *
+	 * @param string $user
+	 * @param string|null $byUser
+	 *
+	 * @return void
+	 * @throws GuzzleException
+	 */
+	public function theUserTriesToRemoveAnotherUserFromNonExistentGroupUsingTheGraphAPI(string $user, ?string $byUser = null): void {
+		$groupId = WebDavHelper::generateUUIDv4();
+		$userId = $this->featureContext->getAttributeOfCreatedUser($user, "id");
 		$this->featureContext->setResponse($this->removeUserFromGroup($groupId, $userId, $byUser));
 	}
 
@@ -1680,7 +1703,7 @@ class GraphContext implements Context {
 	}
 
 	/**
-	 * @When /^the administrator "([^"]*)" tries to add the following users to a non-existing group at once using the Graph API$/
+	 * @When /^the administrator "([^"]*)" tries to add the following users to a nonexistent group at once using the Graph API$/
 	 *
 	 * @param string $user
 	 * @param TableNode $table
@@ -1699,7 +1722,7 @@ class GraphContext implements Context {
 	}
 
 	/**
-	 * @When /^the administrator "([^"]*)" tries to add the following non-existing users to a group "([^"]*)" at once using the Graph API$/
+	 * @When /^the administrator "([^"]*)" tries to add the following nonexistent users to a group "([^"]*)" at once using the Graph API$/
 	 *
 	 * @param string $user
 	 * @param string $group
@@ -1720,6 +1743,7 @@ class GraphContext implements Context {
 
 	/**
 	 * @When /^the administrator "([^"]*)" tries to add the following users to a group "([^"]*)" at once using the Graph API$/
+	 * @When /^the administrator "([^"]*)" tries to add the following existent and nonexistent users to a group "([^"]*)" at once using the Graph API$/
 	 *
 	 * @param string $user
 	 * @param string $group
@@ -1733,11 +1757,8 @@ class GraphContext implements Context {
 		$userIds = [];
 		$groupId = $this->featureContext->getAttributeOfCreatedGroup($group, "id");
 		foreach ($table->getHash() as $row) {
-			try {
-				$userIds[] = $this->featureContext->getAttributeOfCreatedUser($row['username'], "id");
-			} catch (Exception $e) {
-				$userIds[] = WebDavHelper::generateUUIDv4();
-			}
+			$userId = $this->featureContext->getAttributeOfCreatedUser($row['username'], "id");
+			$userIds[] = $userId ? $userId : WebDavHelper::generateUUIDv4();
 		}
 		$this->addMultipleUsersToGroup($user, $userIds, $groupId, $table);
 	}
diff --git a/tests/acceptance/features/bootstrap/Provisioning.php b/tests/acceptance/features/bootstrap/Provisioning.php
index 64ae80f9894..f1cf17664bf 100644
--- a/tests/acceptance/features/bootstrap/Provisioning.php
+++ b/tests/acceptance/features/bootstrap/Provisioning.php
@@ -265,9 +265,7 @@ trait Provisioning {
 				);
 			}
 		} else {
-			throw new Exception(
-				__METHOD__ . ": User '$user' does not exist in the created list."
-			);
+			return false;
 		}
 	}
 
@@ -296,9 +294,7 @@ trait Provisioning {
 				);
 			}
 		} else {
-			throw new Exception(
-				__METHOD__ . ": Group '$group' does not exist in the created list."
-			);
+			return false;
 		}
 	}