Repository: zulip/zulip

Issue:
Add error codes in `validate_account_and_subdomain` 
The error cases in `validate_account_and_subdomain` are ones that clients are likely to want to be able to parse, e.g. the mobile/terminal apps will want to know if an authentication error is because of a deactivated realm for error handling.  REALM_DEACTIVATED and USER_DEACTIVATED are probably reasonable error code names.

We should open zulip-mobile and zulip-terminal issues to support the new error codes once we resolve this.

See `ErrorCode` in `zerver/lib/exceptions.py` for how to register these.  

@gnprice FYI.


Discussion:
Hello @zulip/server-api members, this issue was labeled with the "area: api" label, so you may want to check it out!

<!-- areaLabelAddition -->
@zulipbot claim

Resolving patch:
diff --git a/zerver/decorator.py b/zerver/decorator.py
--- a/zerver/decorator.py
+++ b/zerver/decorator.py
@@ -32,7 +32,9 @@
     OrganizationAdministratorRequired,
     OrganizationMemberRequired,
     OrganizationOwnerRequired,
+    RealmDeactivatedError,
     UnsupportedWebhookEventType,
+    UserDeactivatedError,
 )
 from zerver.lib.queue import queue_json_publish
 from zerver.lib.rate_limiter import RateLimitedUser
@@ -268,9 +270,9 @@ def validate_api_key(
 
 def validate_account_and_subdomain(request: HttpRequest, user_profile: UserProfile) -> None:
     if user_profile.realm.deactivated:
-        raise JsonableError(_("This organization has been deactivated"))
+        raise RealmDeactivatedError()
     if not user_profile.is_active:
-        raise JsonableError(_("Account is deactivated"))
+        raise UserDeactivatedError()
 
     # Either the subdomain matches, or we're accessing Tornado from
     # and to localhost (aka spoofing a request as the user).
diff --git a/zerver/lib/exceptions.py b/zerver/lib/exceptions.py
--- a/zerver/lib/exceptions.py
+++ b/zerver/lib/exceptions.py
@@ -50,6 +50,8 @@ class ErrorCode(AbstractEnum):
     UNAUTHENTICATED_USER = ()
     NONEXISTENT_SUBDOMAIN = ()
     RATE_LIMIT_HIT = ()
+    USER_DEACTIVATED = ()
+    REALM_DEACTIVATED = ()
 
 
 class JsonableError(Exception):
@@ -271,6 +273,30 @@ def msg_format() -> str:
         return _("Must be an organization or stream administrator")
 
 
+class UserDeactivatedError(JsonableError):
+    code: ErrorCode = ErrorCode.USER_DEACTIVATED
+    http_status_code = 403
+
+    def __init__(self) -> None:
+        pass
+
+    @staticmethod
+    def msg_format() -> str:
+        return _("Account is deactivated")
+
+
+class RealmDeactivatedError(JsonableError):
+    code: ErrorCode = ErrorCode.REALM_DEACTIVATED
+    http_status_code = 403
+
+    def __init__(self) -> None:
+        pass
+
+    @staticmethod
+    def msg_format() -> str:
+        return _("This organization has been deactivated")
+
+
 class MarkdownRenderingException(Exception):
     pass
 
diff --git a/zerver/openapi/python_examples.py b/zerver/openapi/python_examples.py
--- a/zerver/openapi/python_examples.py
+++ b/zerver/openapi/python_examples.py
@@ -1224,6 +1224,24 @@ def test_missing_request_argument(client: Client) -> None:
     validate_against_openapi_schema(result, "/rest-error-handling", "post", "400_1")
 
 
+def test_user_account_deactivated(client: Client) -> None:
+    request = {
+        "content": "**foo**",
+    }
+    result = client.render_message(request)
+
+    validate_against_openapi_schema(result, "/rest-error-handling", "post", "403_0")
+
+
+def test_realm_deactivated(client: Client) -> None:
+    request = {
+        "content": "**foo**",
+    }
+    result = client.render_message(request)
+
+    validate_against_openapi_schema(result, "/rest-error-handling", "post", "403_1")
+
+
 def test_invalid_stream_error(client: Client) -> None:
     result = client.get_stream_id("nonexistent")
 


Test patch:
diff --git a/zerver/tests/test_decorators.py b/zerver/tests/test_decorators.py
--- a/zerver/tests/test_decorators.py
+++ b/zerver/tests/test_decorators.py
@@ -1066,7 +1066,9 @@ def test_send_deactivated_realm(self) -> None:
                 "to": self.example_email("othello"),
             },
         )
-        self.assert_json_error_contains(result, "has been deactivated", status_code=400)
+        self.assert_json_error_contains(
+            result, "This organization has been deactivated", status_code=403
+        )
 
         result = self.api_post(
             self.example_user("hamlet"),
@@ -1078,7 +1080,9 @@ def test_send_deactivated_realm(self) -> None:
                 "to": self.example_email("othello"),
             },
         )
-        self.assert_json_error_contains(result, "has been deactivated", status_code=401)
+        self.assert_json_error_contains(
+            result, "This organization has been deactivated", status_code=401
+        )
 
     def test_fetch_api_key_deactivated_realm(self) -> None:
         """
@@ -1094,7 +1098,9 @@ def test_fetch_api_key_deactivated_realm(self) -> None:
         realm.deactivated = True
         realm.save()
         result = self.client_post("/json/fetch_api_key", {"password": test_password})
-        self.assert_json_error_contains(result, "has been deactivated", status_code=400)
+        self.assert_json_error_contains(
+            result, "This organization has been deactivated", status_code=403
+        )
 
     def test_webhook_deactivated_realm(self) -> None:
         """
@@ -1107,7 +1113,9 @@ def test_webhook_deactivated_realm(self) -> None:
         url = f"/api/v1/external/jira?api_key={api_key}&stream=jira_custom"
         data = self.webhook_fixture_data("jira", "created_v2")
         result = self.client_post(url, data, content_type="application/json")
-        self.assert_json_error_contains(result, "has been deactivated", status_code=400)
+        self.assert_json_error_contains(
+            result, "This organization has been deactivated", status_code=403
+        )
 
 
 class LoginRequiredTest(ZulipTestCase):
@@ -1209,7 +1217,7 @@ def test_send_deactivated_user(self) -> None:
                 "to": self.example_email("othello"),
             },
         )
-        self.assert_json_error_contains(result, "Account is deactivated", status_code=400)
+        self.assert_json_error_contains(result, "Account is deactivated", status_code=403)
 
         result = self.api_post(
             self.example_user("hamlet"),
@@ -1238,7 +1246,7 @@ def test_fetch_api_key_deactivated_user(self) -> None:
         change_user_is_active(user_profile, False)
 
         result = self.client_post("/json/fetch_api_key", {"password": test_password})
-        self.assert_json_error_contains(result, "Account is deactivated", status_code=400)
+        self.assert_json_error_contains(result, "Account is deactivated", status_code=403)
 
     def test_login_deactivated_user(self) -> None:
         """
@@ -1304,7 +1312,7 @@ def test_webhook_deactivated_user(self) -> None:
         url = f"/api/v1/external/jira?api_key={api_key}&stream=jira_custom"
         data = self.webhook_fixture_data("jira", "created_v2")
         result = self.client_post(url, data, content_type="application/json")
-        self.assert_json_error_contains(result, "Account is deactivated", status_code=400)
+        self.assert_json_error_contains(result, "Account is deactivated", status_code=403)
 
 
 class TestIncomingWebhookBot(ZulipTestCase):
@@ -1647,7 +1655,9 @@ def test_authenticated_json_post_view_if_user_is_not_active(self) -> None:
         self.login_user(user_profile)
         # we deactivate user manually because do_deactivate_user removes user session
         change_user_is_active(user_profile, False)
-        self.assert_json_error_contains(self._do_test(user_profile), "Account is deactivated")
+        self.assert_json_error_contains(
+            self._do_test(user_profile), "Account is deactivated", status_code=403
+        )
         do_reactivate_user(user_profile, acting_user=None)
 
     def test_authenticated_json_post_view_if_user_realm_is_deactivated(self) -> None:
@@ -1657,7 +1667,9 @@ def test_authenticated_json_post_view_if_user_realm_is_deactivated(self) -> None
         user_profile.realm.deactivated = True
         user_profile.realm.save()
         self.assert_json_error_contains(
-            self._do_test(user_profile), "This organization has been deactivated"
+            self._do_test(user_profile),
+            "This organization has been deactivated",
+            status_code=403,
         )
         do_reactivate_realm(user_profile.realm)