From 49efab69580e467452d34c2ee70c3ead9bf5616e Mon Sep 17 00:00:00 2001 From: Julien Castiaux Date: Tue, 3 May 2022 14:44:06 +0000 Subject: [PATCH] [IMP] core: utility to reraise errors as others We introduce a new decorator/context-manager to catch some exceptions and re-raise them as another error. This utility main's purpose is to hide a route that a user has no access to behind a fake HTTP 404 Page not Found error. The utility is at `odoo.tools.misc.replace_exceptions(*exceptions, by)`. Its usage is as follow: @route('/some/route', auth='public') @replace_exceptions(AccessError, AccessDenied, by=NotFound()) def some_route(self): if not request.session.uid: raise AccessError("Must be connected to see this route") ... Or as a context-manager if you don't want to except an entire function: @route('/some/route', auth='public') def some_route(self): with replace_exceptions(AccessError, AccessDenied, by=NotFound()): if not request.session.uid: raise AccessError("Must be connected to see this route") ... Task: 2800772 Close: #90433 Part-of: odoo/odoo#88134 --- odoo/addons/test_http/controllers.py | 19 +++++++++- odoo/addons/test_http/tests/__init__.py | 1 + odoo/addons/test_http/tests/test_error.py | 26 ++++++++++++++ odoo/tools/misc.py | 43 ++++++++++++++++++++++- 4 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 odoo/addons/test_http/tests/test_error.py diff --git a/odoo/addons/test_http/controllers.py b/odoo/addons/test_http/controllers.py index a7dc5b9a5f8..1a2784b8c3f 100644 --- a/odoo/addons/test_http/controllers.py +++ b/odoo/addons/test_http/controllers.py @@ -3,8 +3,9 @@ import json import logging import werkzeug from odoo import http -from odoo.exceptions import UserError +from odoo.exceptions import AccessError, UserError from odoo.http import request +from odoo.tools import reraise_x_as from odoo.addons.web.controllers.utils import ensure_db @@ -148,3 +149,19 @@ class TestHttp(http.Controller): @http.route('/test_http/json_value_error', type='json', auth='none') def json_value_error(self): raise ValueError('Unknown destination') + + @http.route('/test_http/hide_errors/decorator', type='http', auth='none') + @reraise_x_as(AccessError, as_=werkzeug.exceptions.NotFound()) + def hide_errors_deco(self, error): + if error == 'AccessError': + raise AccessError("Wrong iris code") + if error == 'UserError': + raise UserError("Walter is AFK") + + @http.route('/test_http/hide_errors/context-manager', type='http', auth='none') + def hide_errors_cm(self, error): + with reraise_x_as(AccessError, as_=werkzeug.exceptions.NotFound()): + if error == 'AccessError': + raise AccessError("Wrong iris code") + if error == 'UserError': + raise UserError("Walter is AFK") diff --git a/odoo/addons/test_http/tests/__init__.py b/odoo/addons/test_http/tests/__init__.py index 451f37f515b..e0be1dc5bd2 100644 --- a/odoo/addons/test_http/tests/__init__.py +++ b/odoo/addons/test_http/tests/__init__.py @@ -1 +1,2 @@ +from . import test_error from . import test_http diff --git a/odoo/addons/test_http/tests/test_error.py b/odoo/addons/test_http/tests/test_error.py new file mode 100644 index 00000000000..b43971d48be --- /dev/null +++ b/odoo/addons/test_http/tests/test_error.py @@ -0,0 +1,26 @@ +from odoo.tools import mute_logger +from .test_http import TestHttpBase + + +class TestHttpErrorHttp(TestHttpBase): + @mute_logger('odoo.http') # UserError("Walter is AFK") + def test_httperror0_exceptions_as_404(self): + with self.subTest('Decorator/AccessError'): + res = self.nodb_url_open('/test_http/hide_errors/decorator?error=AccessError') + self.assertEqual(res.status_code, 404, "AccessError are configured to be hidden, they should be re-thrown as NotFound") + self.assertNotIn("Wrong iris code", res.text, "The real AccessError message should be hidden.") + + with self.subTest('Decorator/UserError'): + res = self.nodb_url_open('/test_http/hide_errors/decorator?error=UserError') + self.assertEqual(res.status_code, 400, "UserError are not configured to be hidden, they should be kept as-is.") + self.assertIn("Walter is AFK", res.text, "The real UserError message should be kept") + + with self.subTest('Context-Manager/AccessError'): + res = self.nodb_url_open('/test_http/hide_errors/context-manager?error=AccessError') + self.assertEqual(res.status_code, 404, "AccessError are configured to be hidden, they should be re-thrown as NotFound") + self.assertNotIn("Wrong iris code", res.text, "The real AccessError message should be hidden.") + + with self.subTest('Context-Manager/UserError'): + res = self.nodb_url_open('/test_http/hide_errors/context-manager?error=UserError') + self.assertEqual(res.status_code, 400, "UserError are not configured to be hidden, they should be kept as-is.") + self.assertIn("Walter is AFK", res.text, "The real UserError message should be kept") diff --git a/odoo/tools/misc.py b/odoo/tools/misc.py index 93dab1664cf..e0868bd9741 100644 --- a/odoo/tools/misc.py +++ b/odoo/tools/misc.py @@ -25,7 +25,7 @@ import types import unicodedata from collections import OrderedDict from collections.abc import Iterable, Mapping, MutableMapping, MutableSet -from contextlib import contextmanager +from contextlib import ContextDecorator, contextmanager from difflib import HtmlDiff from functools import wraps from itertools import islice, groupby as itergroupby @@ -1261,6 +1261,47 @@ def ignore(*exc): except exc: pass +class replace_exceptions(ContextDecorator): + """ + Hide some exceptions behind another error. Can be used as a function + decorator or as a context manager. + + .. code-block: + + @route('/super/secret/route', auth='public') + @replace_exceptions(AccessError, by=NotFound()) + def super_secret_route(self): + if not request.session.uid: + raise AccessError("Route hidden to non logged-in users") + ... + + def some_util(): + ... + with replace_exceptions(ValueError, by=UserError("Invalid argument")): + ... + ... + + :param exceptions: the exception classes to catch and replace. + :param by: the exception to raise instead. + """ + def __init__(self, *exceptions, by): + if not exceptions: + raise ValueError("Missing exceptions") + + wrong_exc = next((exc for exc in exceptions if not issubclass(exc, Exception)), None) + if wrong_exc: + raise TypeError(f"{wrong_exc} is not an exception class.") + + self.exceptions = exceptions + self.by = by + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_type is not None and issubclass(exc_type, self.exceptions): + raise self.by from exc_value + html_escape = markupsafe.escape def get_lang(env, lang_code=False):