From e89bc541ed715778e137275af7cad8081c9e974e Mon Sep 17 00:00:00 2001 From: Silviu Panaite Date: Fri, 20 Mar 2026 08:05:12 +0000 Subject: [PATCH 1/2] Implement RFC 7807 error handling with compatibility mode (#165) --- docs/source/http-status-codes.md | 178 ++++++++++++++++ news/165.feature | 1 + src/plone/restapi/configure.zcml | 6 + src/plone/restapi/errorhandling.py | 70 ++++++ src/plone/restapi/problem_types.py | 200 ++++++++++++++++++ src/plone/restapi/services/__init__.py | 42 ++++ .../tests/test_error_handling_rfc7807.py | 76 +++++++ .../restapi/tests/test_functional_auth.py | 4 +- src/plone/restapi/tests/test_permissions.py | 2 +- 9 files changed, 576 insertions(+), 3 deletions(-) create mode 100644 news/165.feature create mode 100644 src/plone/restapi/errorhandling.py create mode 100644 src/plone/restapi/problem_types.py create mode 100644 src/plone/restapi/tests/test_error_handling_rfc7807.py diff --git a/docs/source/http-status-codes.md b/docs/source/http-status-codes.md index 66eb3a6c4f..9fc88c7330 100644 --- a/docs/source/http-status-codes.md +++ b/docs/source/http-status-codes.md @@ -12,6 +12,183 @@ myst: This is the list of HTTP status codes that are used in `plone.restapi`. Here is a [full list of all HTTP status codes](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes). +## Error Response Format (RFC 7807) + +All error responses follow [RFC 7807 (Problem Details for HTTP APIs)](https://tools.ietf.org/html/rfc7807) format. + +### OpenAPI Schema + +For OpenAPI/Swagger documentation, use the `RFC7807Error` schema: + +```yaml +RFC7807Error: + type: object + properties: + type: + type: string + format: uri + description: A URI reference that identifies the problem type. + example: /problem-types/validation-error + title: + type: string + description: A short, human-readable summary of the problem type. + example: Bad Request + status: + type: integer + description: The HTTP status code. + example: 400 + detail: + type: string + description: A human-readable explanation specific to this occurrence of the problem. + example: Login and password must be provided in body. + instance: + type: string + format: uri + description: The request path that caused the error. + example: /plone/@login + message: + type: string + description: "[DEPRECATED] Human-readable error message. Same as 'detail'. Will be removed in future releases." + example: Login and password must be provided in body. + deprecated: true + context: + type: string + format: uri + description: "[DEPRECATED] URL of the closest visible context. Will be removed in future releases." + example: https://example.com/plone + deprecated: true + error_type: + type: string + description: "[DEPRECATED] Legacy field for backwards compatibility. Will be removed in future releases." + example: Missing credentials + deprecated: true + traceback: + type: array + items: + type: string + description: "[DEPRECATED] Stack trace for debugging. Only visible to users with ManagePortal permission. Will be removed in future releases." + deprecated: true + required: + - type + - title + - status + - detail +``` + +### Response Fields + +| Field | Type | Description | +|-------|------|-------------| +| `type` | string (URI) | A relative URI that identifies the problem type | +| `title` | string | A short, human-readable summary of the problem | +| `status` | integer | The HTTP status code | +| `detail` | string | A human-readable explanation specific to this occurrence | +| `instance` | string | The request path that caused the error | + +### Backwards Compatible Fields (DEPRECATED) + +For backwards compatibility, error responses also include these fields. +**They will be removed in future releases.** + +| Field | Type | Description | +|-------|------|-------------| +| `message` | string | **DEPRECATED** - The error message (same as `detail`) | +| `context` | string | **DEPRECATED** - URL of the closest visible context | +| `traceback` | array | **DEPRECATED** - Stack trace (only visible to users with `ManagePortal` permission) | +| `error_type` | string | **DEPRECATED** - Legacy error type identifier | + +### Backwards Compatibility Configuration + +By default, deprecated fields are included in error responses for backwards compatibility. +You can disable this to get a cleaner RFC 7807-only response: + +```python +from plone.restapi.problem_types import set_backwards_compat + +# Disable deprecated fields in error responses +set_backwards_compat(False) + +# Re-enable (default) +set_backwards_compat(True) +``` + +When disabled, error responses will only contain RFC 7807 fields: +- `type`, `title`, `status`, `detail`, `instance` + +When enabled (default), error responses will also include: +- `message`, `context`, `error_type`, `traceback` (deprecated) + +### Example Responses + +**400 Bad Request (Validation Error):** + +```json +{ + "type": "/problem-types/validation-error", + "title": "Bad Request", + "status": 400, + "detail": "Login and password must be provided in body.", + "instance": "/plone/@login", + "message": "Login and password must be provided in body.", + "error_type": "Missing credentials" +} +``` + +**401 Unauthorized (Invalid Credentials):** + +```json +{ + "type": "/problem-types/invalid-credentials", + "title": "Unauthorized", + "status": 401, + "detail": "Wrong login and/or password.", + "instance": "/plone/@login", + "message": "Wrong login and/or password.", + "error_type": "Invalid credentials" +} +``` + +**403 Forbidden:** + +```json +{ + "type": "/problem-types/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "You do not have permission to access this resource.", + "instance": "/plone/document", + "message": "You do not have permission to access this resource." +} +``` + +**404 Not Found:** + +```json +{ + "type": "/problem-types/resource-not-found", + "title": "Not Found", + "status": 404, + "detail": "The requested resource could not be found.", + "instance": "/plone/non-existent", + "message": "The requested resource could not be found." +} +``` + +### Problem Types + +| Problem Type | URI | HTTP Status | +|--------------|-----|-------------| +| Validation Error | `/problem-types/validation-error` | 400 | +| Missing Credentials | `/problem-types/missing-credentials` | 400 | +| Invalid Credentials | `/problem-types/invalid-credentials` | 401 | +| Unauthorized | `/problem-types/unauthorized` | 401 | +| Forbidden | `/problem-types/forbidden` | 403 | +| Resource Not Found | `/problem-types/resource-not-found` | 404 | +| Conflict | `/problem-types/conflict` | 409 | +| Internal Error | `/problem-types/internal-error` | 500 | + +## HTTP Status Codes + ```{glossary} :sorted: true @@ -59,3 +236,4 @@ Here is a [full list of all HTTP status codes](https://en.wikipedia.org/wiki/Lis 500 Internal Server Error The server failed to fulfill an apparently valid request. ``` + diff --git a/news/165.feature b/news/165.feature new file mode 100644 index 0000000000..729ee95d73 --- /dev/null +++ b/news/165.feature @@ -0,0 +1 @@ +Implement RFC 7807 Problem Details for error responses. Errors now include standardized `type`, `title`, `status`, `detail`, and `instance` fields. Exceptions are logged to stderr with full traceback. Messages are translated via i18n. Backwards compatibility maintained with `message`, `context`, and `traceback` fields. Fixes #165. diff --git a/src/plone/restapi/configure.zcml b/src/plone/restapi/configure.zcml index 6d6ab0bace..553155e1a6 100644 --- a/src/plone/restapi/configure.zcml +++ b/src/plone/restapi/configure.zcml @@ -152,4 +152,10 @@ provides="plone.restapi.interfaces.IBlockFieldLinkIntegrityRetriever" /> + + diff --git a/src/plone/restapi/errorhandling.py b/src/plone/restapi/errorhandling.py new file mode 100644 index 0000000000..7e78ec6aa3 --- /dev/null +++ b/src/plone/restapi/errorhandling.py @@ -0,0 +1,70 @@ +from plone.rest.errors import ErrorHandling as BaseErrorHandling +from plone.restapi.problem_types import BACKWARDS_COMPAT_MODE +from plone.restapi.problem_types import INTERNAL_ERROR +from plone.restapi.problem_types import STATUS_MAP +from plone.restapi.problem_types import get_backwards_compat +from plone.restapi.problem_types import translate_message + +import logging +import sys +import traceback + +logger = logging.getLogger("plone.restapi") + + +class ErrorHandling(BaseErrorHandling): + """Extended error handling for plone.restapi. + + - Logs exceptions to stderr with full traceback + - Extends plone.rest error format with RFC 7807 fields + - Optionally maintains backwards compatibility + """ + + def __call__(self): + exception = self.context + self._log_exception(exception) + return super().__call__() + + def _log_exception(self, exception): + """Log exception with full traceback to stderr.""" + exc_info = sys.exc_info() + tb = "".join(traceback.format_exception(*exc_info)) + logger.error( + "Exception during request %s %s:\n%s", + self.request.get("METHOD"), + self.request.get("PATH_INFO"), + tb, + ) + + def render_exception(self, exception): + result = super().render_exception(exception) + + if result is None: + return None + + status = self.request.response.getStatus() + problem_type, title = STATUS_MAP.get( + status, (INTERNAL_ERROR, "Internal Server Error") + ) + + message = result.get("message", "") + translated_message = translate_message(message, self.request) + + error_response = { + "type": problem_type, + "title": title, + "status": status, + "detail": translated_message, + "instance": self.request.get("PATH_INFO", ""), + } + + if get_backwards_compat(): + error_response["message"] = translated_message + if "context" in result: + error_response["context"] = result["context"] + if "traceback" in result: + error_response["traceback"] = result["traceback"] + if "type" in result: + error_response["error_type"] = result["type"] + + return error_response diff --git a/src/plone/restapi/problem_types.py b/src/plone/restapi/problem_types.py new file mode 100644 index 0000000000..516b96f7a0 --- /dev/null +++ b/src/plone/restapi/problem_types.py @@ -0,0 +1,200 @@ +from zope.i18n import translate +from zope.i18nmessageid import Message + +VALIDATION_ERROR = "/problem-types/validation-error" +MISSING_CREDENTIALS = "/problem-types/missing-credentials" +INVALID_CREDENTIALS = "/problem-types/invalid-credentials" +UNAUTHORIZED = "/problem-types/unauthorized" +FORBIDDEN = "/problem-types/forbidden" +RESOURCE_NOT_FOUND = "/problem-types/resource-not-found" +INTERNAL_ERROR = "/problem-types/internal-error" +QUERY_ERROR = "/problem-types/query-error" +CONFLICT = "/problem-types/conflict" + +STATUS_MAP = { + 400: (VALIDATION_ERROR, "Bad Request"), + 401: (UNAUTHORIZED, "Unauthorized"), + 403: (FORBIDDEN, "Forbidden"), + 404: (RESOURCE_NOT_FOUND, "Not Found"), + 409: (CONFLICT, "Conflict"), + 500: (INTERNAL_ERROR, "Internal Server Error"), +} + +BACKWARDS_COMPAT_MODE = True + + +def set_backwards_compat(enabled): + """Enable or disable backwards compatible error fields. + + When disabled, error responses will only contain RFC 7807 fields: + - type, title, status, detail, instance + + When enabled (default), error responses will also include deprecated fields: + - message, context, error_type, traceback + """ + global BACKWARDS_COMPAT_MODE + BACKWARDS_COMPAT_MODE = enabled + + +def get_backwards_compat(): + """Return current backwards compatibility setting.""" + return BACKWARDS_COMPAT_MODE + +RFC7807_ERROR_SCHEMA = { + "type": "object", + "properties": { + "type": { + "type": "string", + "format": "uri", + "description": "A URI reference that identifies the problem type.", + "example": "/problem-types/validation-error", + }, + "title": { + "type": "string", + "description": "A short, human-readable summary of the problem type.", + "example": "Bad Request", + }, + "status": { + "type": "integer", + "description": "The HTTP status code.", + "example": 400, + }, + "detail": { + "type": "string", + "description": "A human-readable explanation specific to this occurrence of the problem.", + "example": "Login and password must be provided in body.", + }, + "instance": { + "type": "string", + "format": "uri", + "description": "The request path that caused the error.", + "example": "/plone/@login", + }, + "message": { + "type": "string", + "description": "[DEPRECATED] Human-readable error message. Same as 'detail'. Will be removed in future releases.", + "example": "Login and password must be provided in body.", + "deprecated": True, + }, + "context": { + "type": "string", + "format": "uri", + "description": "[DEPRECATED] URL of the closest visible context. Will be removed in future releases.", + "example": "https://example.com/plone", + "deprecated": True, + }, + "error_type": { + "type": "string", + "description": "[DEPRECATED] Legacy field for backwards compatibility. Will be removed in future releases.", + "example": "Missing credentials", + "deprecated": True, + }, + "traceback": { + "type": "array", + "items": {"type": "string"}, + "description": "[DEPRECATED] Stack trace for debugging. Only visible to users with ManagePortal permission. Will be removed in future releases.", + "deprecated": True, + }, + }, + "required": ["type", "title", "status", "detail"], +} + +OPENAPI_RESPONSES = { + 400: { + "description": "Bad Request - The request was invalid or cannot be served.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/validation-error", + "title": "Bad Request", + "status": 400, + "detail": "Login and password must be provided in body.", + "instance": "/plone/@login", + }, + } + }, + }, + 401: { + "description": "Unauthorized - Authentication is required.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/unauthorized", + "title": "Unauthorized", + "status": 401, + "detail": "Authentication required.", + "instance": "/plone/@login", + }, + } + }, + }, + 403: { + "description": "Forbidden - The request was valid but access is denied.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/forbidden", + "title": "Forbidden", + "status": 403, + "detail": "You do not have permission to access this resource.", + "instance": "/plone/document", + }, + } + }, + }, + 404: { + "description": "Not Found - The requested resource could not be found.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/resource-not-found", + "title": "Not Found", + "status": 404, + "detail": "The requested resource could not be found.", + "instance": "/plone/non-existent", + }, + } + }, + }, + 409: { + "description": "Conflict - The request conflicts with the current state.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/conflict", + "title": "Conflict", + "status": 409, + "detail": "The resource has been modified by another request.", + "instance": "/plone/document", + }, + } + }, + }, + 500: { + "description": "Internal Server Error - An unexpected error occurred.", + "content": { + "application/json": { + "schema": {"$ref": "#/components/schemas/RFC7807Error"}, + "example": { + "type": "/problem-types/internal-error", + "title": "Internal Server Error", + "status": 500, + "detail": "An unexpected error occurred.", + "instance": "/plone/@login", + }, + } + }, + }, +} + + +def translate_message(msg, request): + """Translate message if it's a Message object, otherwise return as-is.""" + if isinstance(msg, Message): + return translate(msg, context=request) + return msg diff --git a/src/plone/restapi/services/__init__.py b/src/plone/restapi/services/__init__.py index 21e14e5128..1b969a19f8 100644 --- a/src/plone/restapi/services/__init__.py +++ b/src/plone/restapi/services/__init__.py @@ -1,6 +1,10 @@ from AccessControl.SecurityManagement import getSecurityManager from plone.rest import Service as RestService from plone.restapi.permissions import UseRESTAPI +from plone.restapi.problem_types import get_backwards_compat +from plone.restapi.problem_types import INTERNAL_ERROR +from plone.restapi.problem_types import STATUS_MAP +from plone.restapi.problem_types import translate_message from zExceptions import Unauthorized import json @@ -17,6 +21,12 @@ def render(self): self.check_permission() content = self.reply() if content is not _no_content_marker: + if ( + not get_backwards_compat() + and isinstance(content, dict) + and "error" in content + ): + content = self._convert_error_to_rfc7807(content) self.request.response.setHeader("Content-Type", self.content_type) return json.dumps( content, indent=2, sort_keys=True, separators=(", ", ": ") @@ -36,3 +46,35 @@ def reply(self): def reply_no_content(self, status=204): self.request.response.setStatus(status) return _no_content_marker + + def _convert_error_to_rfc7807(self, error_dict): + """Convert legacy error format to RFC 7807 Problem Details. + + Takes a dict with an 'error' key and converts it to RFC 7807 format. + Backwards compatible fields are included based on the backwards compat flag. + Preserves special fields like 'errors' for form validation errors. + """ + error = error_dict.get("error", {}) + status = self.request.response.getStatus() + problem_type, title = STATUS_MAP.get( + status, (INTERNAL_ERROR, "Internal Server Error") + ) + + message = error.get("message", "") + translated_message = translate_message(message, self.request) + + error_response = { + "type": problem_type, + "title": title, + "status": status, + "detail": translated_message, + "instance": self.request.get("PATH_INFO", ""), + } + + if get_backwards_compat(): + error_response["message"] = translated_message + error_response["error_type"] = error.get("type", "") + if "errors" in error: + error_response["errors"] = error["errors"] + + return error_response diff --git a/src/plone/restapi/tests/test_error_handling_rfc7807.py b/src/plone/restapi/tests/test_error_handling_rfc7807.py new file mode 100644 index 0000000000..92516fa8eb --- /dev/null +++ b/src/plone/restapi/tests/test_error_handling_rfc7807.py @@ -0,0 +1,76 @@ +from plone.app.testing import SITE_OWNER_NAME +from plone.app.testing import SITE_OWNER_PASSWORD +from plone.restapi.problem_types import set_backwards_compat +from plone.restapi.testing import PLONE_RESTAPI_DX_FUNCTIONAL_TESTING +from plone.restapi.testing import RelativeSession + +import unittest + + +class TestRFC7807ErrorHandling(unittest.TestCase): + + layer = PLONE_RESTAPI_DX_FUNCTIONAL_TESTING + + def setUp(self): + self.portal = self.layer["portal"] + self.portal_url = self.portal.absolute_url() + + self.api_session = RelativeSession(self.portal_url, test=self) + self.api_session.headers.update({"Accept": "application/json"}) + self.api_session.auth = (SITE_OWNER_NAME, SITE_OWNER_PASSWORD) + + set_backwards_compat(False) + + def tearDown(self): + set_backwards_compat(True) + self.api_session.close() + + def test_login_with_missing_credentials_uses_rfc7807(self): + response = self.api_session.post("@login", json={"invalid": "data"}) + + self.assertEqual(400, response.status_code) + data = response.json() + self.assertEqual(data["type"], "/problem-types/validation-error") + self.assertEqual(data["title"], "Bad Request") + self.assertEqual(data["status"], 400) + self.assertIn("detail", data) + self.assertIn("instance", data) + self.assertNotIn("error", data) + self.assertNotIn("message", data) + self.assertNotIn("error_type", data) + self.assertNotIn("context", data) + self.assertNotIn("traceback", data) + + def test_login_with_invalid_credentials_uses_rfc7807(self): + response = self.api_session.post( + "@login", json={"login": "invalid", "password": "invalid"} + ) + + self.assertEqual(401, response.status_code) + data = response.json() + self.assertEqual(data["type"], "/problem-types/unauthorized") + self.assertEqual(data["title"], "Unauthorized") + self.assertEqual(data["status"], 401) + self.assertEqual(data["detail"], "Wrong login and/or password.") + self.assertIn("instance", data) + self.assertNotIn("error", data) + self.assertNotIn("message", data) + self.assertNotIn("error_type", data) + self.assertNotIn("context", data) + self.assertNotIn("traceback", data) + + def test_unauthorized_response_uses_only_rfc7807_fields(self): + self.api_session.auth = None + response = self.api_session.get("@users") + + self.assertEqual(401, response.status_code) + data = response.json() + self.assertEqual(data["type"], "/problem-types/unauthorized") + self.assertEqual(data["title"], "Unauthorized") + self.assertEqual(data["status"], 401) + self.assertIn("detail", data) + self.assertIn("instance", data) + self.assertEqual( + set(data.keys()), + {"type", "title", "status", "detail", "instance"}, + ) diff --git a/src/plone/restapi/tests/test_functional_auth.py b/src/plone/restapi/tests/test_functional_auth.py index dd1e44bc18..abe395a883 100644 --- a/src/plone/restapi/tests/test_functional_auth.py +++ b/src/plone/restapi/tests/test_functional_auth.py @@ -263,7 +263,7 @@ def test_accessing_private_document_with_invalid_token_fails(self): ) self.assertEqual(401, response.status_code) - self.assertEqual("Unauthorized", response.json().get("type")) + self.assertEqual("Unauthorized", response.json().get("error_type")) self.assertEqual( "You are not authorized to access this resource.", response.json().get("message"), @@ -286,7 +286,7 @@ def test_accessing_private_document_with_expired_token_fails(self): ) self.assertEqual(401, response.status_code) - self.assertEqual("Unauthorized", response.json().get("type")) + self.assertEqual("Unauthorized", response.json().get("error_type")) self.assertEqual( "You are not authorized to access this resource.", response.json().get("message"), diff --git a/src/plone/restapi/tests/test_permissions.py b/src/plone/restapi/tests/test_permissions.py index bcd5bfd8e7..3ecbf1ab41 100644 --- a/src/plone/restapi/tests/test_permissions.py +++ b/src/plone/restapi/tests/test_permissions.py @@ -56,7 +56,7 @@ def test_unauthorized_if_missing_permission(self): response = self.api_session.get(self.portal_url) self.assertEqual(response.status_code, 401) data = response.json() - self.assertEqual(data["type"], "Unauthorized") + self.assertEqual(data["error_type"], "Unauthorized") self.assertEqual( data["message"], "Missing 'plone.restapi: Use REST API' permission" ) From 59cd5c83be710d004f62fbd8e486328621c38cd0 Mon Sep 17 00:00:00 2001 From: Silviu Panaite Date: Fri, 20 Mar 2026 11:33:56 +0000 Subject: [PATCH 2/2] Preserve legacy error type in compatibility mode (#165) --- src/plone/restapi/errorhandling.py | 17 +++++++---------- src/plone/restapi/tests/test_functional_auth.py | 4 ++-- src/plone/restapi/tests/test_permissions.py | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/plone/restapi/errorhandling.py b/src/plone/restapi/errorhandling.py index 7e78ec6aa3..febe43116c 100644 --- a/src/plone/restapi/errorhandling.py +++ b/src/plone/restapi/errorhandling.py @@ -1,5 +1,4 @@ from plone.rest.errors import ErrorHandling as BaseErrorHandling -from plone.restapi.problem_types import BACKWARDS_COMPAT_MODE from plone.restapi.problem_types import INTERNAL_ERROR from plone.restapi.problem_types import STATUS_MAP from plone.restapi.problem_types import get_backwards_compat @@ -42,6 +41,13 @@ def render_exception(self, exception): if result is None: return None + if get_backwards_compat(): + legacy_result = dict(result) + legacy_result["message"] = translate_message( + legacy_result.get("message", ""), self.request + ) + return legacy_result + status = self.request.response.getStatus() problem_type, title = STATUS_MAP.get( status, (INTERNAL_ERROR, "Internal Server Error") @@ -58,13 +64,4 @@ def render_exception(self, exception): "instance": self.request.get("PATH_INFO", ""), } - if get_backwards_compat(): - error_response["message"] = translated_message - if "context" in result: - error_response["context"] = result["context"] - if "traceback" in result: - error_response["traceback"] = result["traceback"] - if "type" in result: - error_response["error_type"] = result["type"] - return error_response diff --git a/src/plone/restapi/tests/test_functional_auth.py b/src/plone/restapi/tests/test_functional_auth.py index abe395a883..dd1e44bc18 100644 --- a/src/plone/restapi/tests/test_functional_auth.py +++ b/src/plone/restapi/tests/test_functional_auth.py @@ -263,7 +263,7 @@ def test_accessing_private_document_with_invalid_token_fails(self): ) self.assertEqual(401, response.status_code) - self.assertEqual("Unauthorized", response.json().get("error_type")) + self.assertEqual("Unauthorized", response.json().get("type")) self.assertEqual( "You are not authorized to access this resource.", response.json().get("message"), @@ -286,7 +286,7 @@ def test_accessing_private_document_with_expired_token_fails(self): ) self.assertEqual(401, response.status_code) - self.assertEqual("Unauthorized", response.json().get("error_type")) + self.assertEqual("Unauthorized", response.json().get("type")) self.assertEqual( "You are not authorized to access this resource.", response.json().get("message"), diff --git a/src/plone/restapi/tests/test_permissions.py b/src/plone/restapi/tests/test_permissions.py index 3ecbf1ab41..bcd5bfd8e7 100644 --- a/src/plone/restapi/tests/test_permissions.py +++ b/src/plone/restapi/tests/test_permissions.py @@ -56,7 +56,7 @@ def test_unauthorized_if_missing_permission(self): response = self.api_session.get(self.portal_url) self.assertEqual(response.status_code, 401) data = response.json() - self.assertEqual(data["error_type"], "Unauthorized") + self.assertEqual(data["type"], "Unauthorized") self.assertEqual( data["message"], "Missing 'plone.restapi: Use REST API' permission" )