[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 <juc@odoo.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'])
|
||||
Reference in New Issue
Block a user