Repository: apache/airflow

Issue:
Unable to change username while updating user information through API
### Apache Airflow version

2.2.0b2 (beta snapshot)

### Operating System

Debian buster

### Versions of Apache Airflow Providers

_No response_

### Deployment

Astronomer

### Deployment details

Astro dev start

### What happened

Unable to change username while updating user information through API though it's possible from UI.

### What you expected to happen

Either username should not be editable from UI or It should be editable from API.

### How to reproduce

1. Use a patch request with the below URL.
`{{url}}/users/{{username}}`
2. In payload use a different username
```
 {
        "username": "{{username1}}",
        "password": "password1",
        "email": "{{email}}@example.com",
        "first_name": "{{$randomFirstName}}",
        "last_name": "{{$randomLastName}}",
        "roles":[{ "name": "Op"}]
}
```


### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)

"Already exists." message is missing while updating user email with existing email id through API
### Apache Airflow version

2.2.0b2 (beta snapshot)

### Operating System

Debian buster

### Versions of Apache Airflow Providers

_No response_

### Deployment

Astronomer

### Deployment details

Astro dev start

### What happened

"Already exists." error message is missing while updating user email with existing email id through API.

### What you expected to happen

"Email id already exists" error message should appear

### How to reproduce

1. Use a patch request with the below URL.
`{{url}}/users/{{username}}`
2. In payload use an exiting email id 
```
 {
        "username": "{{username}}",
        "password": "password1",
        "email": "{{exiting_email}}@example.com",
        "first_name": "{{$randomFirstName}}",
        "last_name": "{{$randomLastName}}",
        "roles":[{ "name": "Op"}]
}
```

### Anything else

_No response_

### Are you willing to submit PR?

- [ ] Yes I am willing to submit a PR!

### Code of Conduct

- [X] I agree to follow this project's [Code of Conduct](https://github.com/apache/airflow/blob/main/CODE_OF_CONDUCT.md)



Discussion:

Resolving patch:
diff --git a/airflow/api_connexion/endpoints/user_endpoint.py b/airflow/api_connexion/endpoints/user_endpoint.py
--- a/airflow/api_connexion/endpoints/user_endpoint.py
+++ b/airflow/api_connexion/endpoints/user_endpoint.py
@@ -112,7 +112,7 @@ def post_user():
 
 @security.requires_access([(permissions.ACTION_CAN_EDIT, permissions.RESOURCE_USER)])
 def patch_user(username, update_mask=None):
-    """Update a role"""
+    """Update a user"""
     try:
         data = user_schema.load(request.json)
     except ValidationError as e:
@@ -124,9 +124,19 @@ def patch_user(username, update_mask=None):
     if user is None:
         detail = f"The User with username `{username}` was not found"
         raise NotFound(title="User not found", detail=detail)
-
-    # Get fields to update. 'username' is always excluded (and it's an error to
-    # include it in update_maek).
+    # Check unique username
+    new_username = data.get('username')
+    if new_username and new_username != username:
+        if security_manager.find_user(username=new_username):
+            raise AlreadyExists(detail=f"The username `{new_username}` already exists")
+
+    # Check unique email
+    email = data.get('email')
+    if email and email != user.email:
+        if security_manager.find_user(email=email):
+            raise AlreadyExists(detail=f"The email `{email}` already exists")
+
+    # Get fields to update.
     if update_mask is not None:
         masked_data = {}
         missing_mask_names = []
@@ -139,11 +149,7 @@ def patch_user(username, update_mask=None):
         if missing_mask_names:
             detail = f"Unknown update masks: {', '.join(repr(n) for n in missing_mask_names)}"
             raise BadRequest(detail=detail)
-        if "username" in masked_data:
-            raise BadRequest("Cannot update fields: 'username'")
         data = masked_data
-    else:
-        data.pop("username", None)
 
     if "roles" in data:
         roles_to_update = []


Test patch:
diff --git a/tests/api_connexion/endpoints/test_user_endpoint.py b/tests/api_connexion/endpoints/test_user_endpoint.py
--- a/tests/api_connexion/endpoints/test_user_endpoint.py
+++ b/tests/api_connexion/endpoints/test_user_endpoint.py
@@ -472,6 +472,65 @@ def test_change_with_update_maek(self, autoclean_username, autoclean_user_payloa
         assert data["first_name"] == "Changed"
         assert data["last_name"] == "User"
 
+    @pytest.mark.parametrize(
+        "payload, error_message",
+        [
+            ({"username": "another_user"}, "The username `another_user` already exists"),
+            ({"email": "another_user@example.com"}, "The email `another_user@example.com` already exists"),
+        ],
+        ids=["username", "email"],
+    )
+    @pytest.mark.usefixtures("user_different")
+    @pytest.mark.usefixtures("autoclean_admin_user")
+    def test_patch_already_exists(
+        self,
+        payload,
+        error_message,
+        autoclean_user_payload,
+        autoclean_username,
+    ):
+        autoclean_user_payload.update(payload)
+        response = self.client.patch(
+            f"/api/v1/users/{autoclean_username}",
+            json=autoclean_user_payload,
+            environ_overrides={"REMOTE_USER": "test"},
+        )
+        assert response.status_code == 409, response.json
+
+        assert response.json["detail"] == error_message
+
+    @pytest.mark.parametrize(
+        "field",
+        ["username", "first_name", "last_name", "email"],
+    )
+    @pytest.mark.usefixtures("autoclean_admin_user")
+    def test_required_fields(
+        self,
+        field,
+        autoclean_user_payload,
+        autoclean_username,
+    ):
+        autoclean_user_payload.pop(field)
+        response = self.client.patch(
+            f"/api/v1/users/{autoclean_username}",
+            json=autoclean_user_payload,
+            environ_overrides={"REMOTE_USER": "test"},
+        )
+        assert response.status_code == 400, response.json
+        assert response.json['detail'] == "{'%s': ['Missing data for required field.']}" % field
+
+    @pytest.mark.usefixtures('autoclean_admin_user')
+    def test_username_can_be_updated(self, autoclean_user_payload, autoclean_username):
+        testusername = 'testusername'
+        autoclean_user_payload.update({"username": testusername})
+        response = self.client.patch(
+            f"/api/v1/users/{autoclean_username}",
+            json=autoclean_user_payload,
+            environ_overrides={"REMOTE_USER": "test"},
+        )
+        _delete_user(username=testusername)
+        assert response.json['username'] == testusername
+
     @pytest.mark.usefixtures("autoclean_admin_user")
     @unittest.mock.patch(
         "airflow.api_connexion.endpoints.user_endpoint.generate_password_hash",