From be1ea4d3c8ed747b081cf249ac7373b161e25bd8 Mon Sep 17 00:00:00 2001 From: tsm-odoo Date: Fri, 16 Sep 2022 15:17:37 +0000 Subject: [PATCH] [FIX] bus: handle session expired in peek notifications In order to know whether or not the websocket session is up to date, the session is saved when opening a websocket connection. Then, for each incoming/outgoing message, we check if the session still exists on the file system. When it does not exist anymore, the websocket connection is refreshed. This ensures the session is always up to date. Odoo sh proxies the websocket connection and needs a way to tell when a websocket session is expired. This commit adds the same check that is done for each incoming websocket message in the websocket peek route in order to raise a `SessionExpiredException` when the session is outdated. This will allow odoo sh to catch this error and to refresh their websocket connection accordingly. closes odoo/odoo#100416 Signed-off-by: Julien Castiaux --- addons/bus/controllers/websocket.py | 9 +- addons/bus/tests/__init__.py | 1 + addons/bus/tests/test_websocket_controller.py | 92 +++++++++++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 addons/bus/tests/test_websocket_controller.py diff --git a/addons/bus/controllers/websocket.py b/addons/bus/controllers/websocket.py index 63048c088d5..b47fefab8e7 100644 --- a/addons/bus/controllers/websocket.py +++ b/addons/bus/controllers/websocket.py @@ -3,7 +3,7 @@ import json from werkzeug.exceptions import ServiceUnavailable -from odoo.http import Controller, request, route +from odoo.http import Controller, request, route, SessionExpiredException from odoo.addons.base.models.assetsbundle import AssetsBundle from ..models.bus import channel_with_db from ..websocket import WebsocketConnectionHandler @@ -32,9 +32,14 @@ class WebsocketController(Controller): return request.make_response(data, headers) @route('/websocket/peek_notifications', type='json', auth='public', cors='*') - def peek_notifications(self, channels, last): + def peek_notifications(self, channels, last, is_first_poll=False): if not all(isinstance(c, str) for c in channels): raise ValueError("bus.Bus only string channels are allowed.") + if is_first_poll: + # Used to detect when the current session is expired. + request.session['is_websocket_session'] = True + elif 'is_websocket_session' not in request.session: + raise SessionExpiredException() channels = list(set( channel_with_db(request.db, c) for c in request.env['ir.websocket']._build_bus_channel_list(channels) diff --git a/addons/bus/tests/__init__.py b/addons/bus/tests/__init__.py index bb3b9125cbb..4bab7610650 100644 --- a/addons/bus/tests/__init__.py +++ b/addons/bus/tests/__init__.py @@ -3,4 +3,5 @@ from . import test_assetsbundle from . import test_health from . import test_ir_websocket from . import test_websocket_caryall +from . import test_websocket_controller from . import test_websocket_rate_limiting diff --git a/addons/bus/tests/test_websocket_controller.py b/addons/bus/tests/test_websocket_controller.py new file mode 100644 index 00000000000..2c477cca6fe --- /dev/null +++ b/addons/bus/tests/test_websocket_controller.py @@ -0,0 +1,92 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import json + +from odoo.tests import common + + +class TestWebsocketController(common.HttpCase): + def _make_rpc(self, route, params, headers=None): + data = json.dumps({ + 'id': 0, + 'jsonrpc': '2.0', + 'method': 'call', + 'params': params, + }).encode() + headers = headers or {} + headers['Content-Type'] = 'application/json' + return self.url_open(route, data, headers=headers) + + def test_websocket_peek(self): + response = json.loads( + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': True, + }).content.decode() + ) + # Response containing channels/notifications is retrieved and is + # conform to excpectations. + result = response.get('result') + self.assertIsNotNone(result) + channels = result.get('channels') + self.assertIsNotNone(channels) + self.assertIsInstance(channels, list) + notifications = result.get('notifications') + self.assertIsNotNone(notifications) + self.assertIsInstance(notifications, list) + + response = json.loads( + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': False, + }).content.decode() + ) + # Reponse is received as long as the session is valid. + self.assertIn('result', response) + + def test_websocket_peek_session_expired_login(self): + session = self.authenticate(None, None) + # first rpc should be fine + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': True, + }) + + self.authenticate('admin', 'admin') + # rpc with outdated session should lead to error. + headers = {'Cookie': f'session_id={session.sid};'} + response = json.loads( + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': False, + }, headers=headers).content.decode() + ) + error = response.get('error') + self.assertIsNotNone(error, 'Sending a poll with an outdated session should lead to error') + self.assertEqual('odoo.http.SessionExpiredException', error['data']['name']) + + def test_websocket_peek_session_expired_logout(self): + session = self.authenticate('demo', 'demo') + # first rpc should be fine + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': True, + }) + self.url_open('/web/session/logout') + # rpc with outdated session should lead to error. + headers = {'Cookie': f'session_id={session.sid};'} + response = json.loads( + self._make_rpc('/websocket/peek_notifications', { + 'channels': [], + 'last': 0, + 'is_first_poll': False, + }, headers=headers).content.decode() + ) + error = response.get('error') + self.assertIsNotNone(error, 'Sending a poll with an outdated session should lead to error') + self.assertEqual('odoo.http.SessionExpiredException', error['data']['name'])