19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:30:27 +01:00
parent d1963a3c3a
commit 2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions

View file

@ -1,8 +1,11 @@
from . import common
from . import test_assetsbundle
from . import test_bus_gc
from . import test_health
from . import test_ir_model
from . import test_ir_websocket
from . import test_notify
from . import test_websocket_caryall
from . import test_close_websocket_after_tour
from . import test_websocket_controller
from . import test_websocket_rate_limiting

View file

@ -5,26 +5,31 @@ import struct
from threading import Event
import unittest
from unittest.mock import patch
import inspect
from werkzeug.exceptions import BadRequest
import contextlib
try:
import websocket
except ImportError:
websocket = None
import odoo.tools
from odoo.tests import HOST, HttpCase, TEST_CURSOR_COOKIE_NAME
from odoo.http import request
from odoo.tests.common import HOST, release_test_lock, TEST_CURSOR_COOKIE_NAME, Like, _registry_test_lock
from odoo.tests import HttpCase
from ..websocket import CloseCode, Websocket, WebsocketConnectionHandler
from ..models.bus import dispatch, hashable, channel_with_db
class WebsocketCase(HttpCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
if websocket is None:
cls._logger.warning("websocket-client module is not installed")
raise unittest.SkipTest("websocket-client module is not installed")
cls._WEBSOCKET_URL = f"ws://{HOST}:{odoo.tools.config['http_port']}/websocket"
cls._BASE_WEBSOCKET_URL = f"ws://{HOST}:{cls.http_port()}/websocket"
cls._WEBSOCKET_URL = f"{cls._BASE_WEBSOCKET_URL}?version={WebsocketConnectionHandler._VERSION}"
websocket_allowed_patch = patch.object(WebsocketConnectionHandler, "websocket_allowed", return_value=True)
cls.startClassPatcher(websocket_allowed_patch)
@ -48,6 +53,8 @@ class WebsocketCase(HttpCase):
wraps=_mocked_serve_forever
)
self.startPatcher(self._serve_forever_patch)
self.enterContext(release_test_lock()) # Release the lock during websocket tests
self.http_request_key = 'websocket'
def tearDown(self):
self._close_websockets()
@ -63,7 +70,28 @@ class WebsocketCase(HttpCase):
ws.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
def websocket_connect(self, *args, **kwargs):
@contextlib.contextmanager
def allow_requests(self, *args, **kwargs):
# As the lock is always unlocked, we reacquire it before allowing request
# to avoid exceptions.
with _registry_test_lock, super().allow_requests(*args, **kwargs):
yield
def assertCanOpenTestCursor(self):
# As the lock is always unlocked during WebsocketCases we have a whitelist of
# methods which must match. We also default to super if we are coming from a cursor.
allowed_methods = [ # function + filename
('acquire_cursor', Like('.../bus/websocket.py')),
]
if any(
frame.function == function and frame.filename == filename
for frame in inspect.stack()
for function, filename in allowed_methods
) or request:
return super().assertCanOpenTestCursor()
raise BadRequest('Opening a cursor from an unknown method in websocket test.')
def websocket_connect(self, *args, ping_after_connect=True, **kwargs):
"""
Connect a websocket. If no cookie is given, the connection is
opened with a default session. The created websocket is closed
@ -72,18 +100,21 @@ class WebsocketCase(HttpCase):
if 'cookie' not in kwargs:
self.session = self.authenticate(None, None)
kwargs['cookie'] = f'session_id={self.session.sid}'
kwargs['cookie'] += f';{TEST_CURSOR_COOKIE_NAME}={self.http_request_key}'
kwargs['timeout'] = 10 # keep a large timeout to avoid aving a websocket request escaping the test
# The cursor lock is already released, we just need to pass the right cookie.
kwargs['cookie'] += f';{TEST_CURSOR_COOKIE_NAME}={self.http_request_key}'
ws = websocket.create_connection(
type(self)._WEBSOCKET_URL, *args, **kwargs
self._WEBSOCKET_URL, *args, **kwargs
)
ws.ping()
ws.recv_data_frame(control_frame=True) # pong
if ping_after_connect:
ws.ping()
ws.recv_data_frame(control_frame=True) # pong
self._websockets.add(ws)
return ws
def subscribe(self, websocket, channels=None, last=None, wait_for_dispatch=True):
""" Subscribe the websocket to the given channels.
:param websocket: The websocket of the client.
:param channels: The list of channels to subscribe to.
:param last: The last notification id the client received.
@ -101,18 +132,33 @@ class WebsocketCase(HttpCase):
sub = {'event_name': 'subscribe', 'data': {
'channels': channels or [],
}}
if last:
if last is not None:
sub['data']['last'] = last
websocket.send(json.dumps(sub))
if wait_for_dispatch:
dispatch_bus_notification_done.wait(timeout=5)
def trigger_notification_dispatching(self, channels):
""" Notify the websockets subscribed to the given channels that new
notifications are available. Usefull since the bus is not able to do
it during tests.
"""
self.env.cr.precommit.run() # trigger the creation of bus.bus records
channels = [
hashable(channel_with_db(self.registry.db_name, c)) for c in channels
]
websockets = set()
for channel in channels:
websockets.update(dispatch._channels_to_ws.get(hashable(channel), []))
for websocket in websockets:
websocket.trigger_notification_dispatching()
def wait_remaining_websocket_connections(self):
""" Wait for the websocket connections to terminate. """
for event in self._websocket_events:
event.wait(5)
def assert_close_with_code(self, websocket, expected_code):
def assert_close_with_code(self, websocket, expected_code, expected_reason=None):
"""
Assert that the websocket is closed with the expected_code.
"""
@ -122,3 +168,12 @@ class WebsocketCase(HttpCase):
code = struct.unpack('!H', payload[:2])[0]
# ensure the close code is the one we expected
self.assertEqual(code, expected_code)
if expected_reason:
# ensure the close reason is the one we expected
self.assertEqual(payload[2:].decode(), expected_reason)
class BusCase:
def _reset_bus(self):
self.env.cr.precommit.run() # trigger the creation of bus.bus records
self.env["bus.bus"].sudo().search([]).unlink()

View file

@ -1,7 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo.tests
from odoo.osv import expression
@odoo.tests.tagged('post_install', '-at_install', 'assets_bundle')
@ -14,18 +12,9 @@ class BusWebTests(odoo.tests.HttpCase):
i.e. their hash has been recomputed and differ from the attachment's
- The interface deals with those bus messages by displaying one notification
"""
db_name = self.env.registry.db_name
bundle_xml_ids = ('web.assets_common', 'web.assets_backend')
domain = []
for bundle in bundle_xml_ids:
domain = expression.OR([
domain,
[('name', 'ilike', bundle + '%')]
])
# start from a clean slate
self.env['ir.attachment'].search(domain).unlink()
self.env.registry._clear_cache()
self.env['ir.attachment'].search([('name', 'ilike', 'web.assets_%')]).unlink()
self.env.registry.clear_cache()
sendones = []
def patched_sendone(self, channel, notificationType, message):
@ -36,13 +25,15 @@ class BusWebTests(odoo.tests.HttpCase):
self.patch(type(self.env['bus.bus']), '_sendone', patched_sendone)
self.authenticate('admin', 'admin')
self.url_open('/web')
self.assertEqual(self.url_open('/web/assets/any/web.assets_web.min.js', allow_redirects=False).status_code, 200)
self.assertEqual(self.url_open('/web/assets/any/web.assets_web.min.css', allow_redirects=False).status_code, 200)
self.assertEqual(self.url_open('/web/assets/any/web.assets_backend.min.js', allow_redirects=False).status_code, 200)
self.assertEqual(self.url_open('/web/assets/any/web.assets_backend.min.css', allow_redirects=False).status_code, 200)
# One sendone for each asset bundle and for each CSS / JS
self.assertEqual(
len(sendones),
4,
2,
'Received %s' % '\n'.join('%s - %s' % (tmp[0], tmp[1]) for tmp in sendones)
)
for (channel, message) in sendones:

View file

@ -0,0 +1,36 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from datetime import datetime, timedelta
from freezegun import freeze_time
from odoo.tests import HttpCase, tagged
from odoo.addons.bus.models.bus import DEFAULT_GC_RETENTION_SECONDS
@tagged("-at_install", "post_install")
class TestBusGC(HttpCase):
def test_default_gc_retention_window(self):
self.env["ir.config_parameter"].search([("key", "=", "bus.gc_retention_seconds")]).unlink()
self.env["bus.bus"].search([]).unlink()
self.env["bus.bus"].create({"channel": "foo", "message": "bar"})
self.assertEqual(self.env["bus.bus"].search_count([]), 1)
with freeze_time(datetime.now() + timedelta(seconds=DEFAULT_GC_RETENTION_SECONDS / 2)):
self.env["bus.bus"]._gc_messages()
self.assertEqual(self.env["bus.bus"].search_count([]), 1)
with freeze_time(datetime.now() + timedelta(seconds=DEFAULT_GC_RETENTION_SECONDS + 1)):
self.env["bus.bus"]._gc_messages()
self.assertEqual(self.env["bus.bus"].search_count([]), 0)
def test_custom_gc_retention_window(self):
self.env["bus.bus"].search([]).unlink()
self.env["ir.config_parameter"].set_param("bus.gc_retention_seconds", 25000)
self.env["bus.bus"].create({"channel": "foo", "message": "bar"})
self.assertEqual(self.env["bus.bus"].search_count([]), 1)
with freeze_time(datetime.now() + timedelta(seconds=15000)):
self.env["bus.bus"]._gc_messages()
self.assertEqual(self.env["bus.bus"].search_count([]), 1)
with freeze_time(datetime.now() + timedelta(seconds=30000)):
self.env["bus.bus"]._gc_messages()
self.assertEqual(self.env["bus.bus"].search_count([]), 0)

View file

@ -0,0 +1,38 @@
import gc
from unittest.mock import patch
from weakref import WeakSet
from odoo.tests import tagged
from .. import websocket as websocket_module
from .common import WebsocketCase
@tagged("-at_install", "post_install")
class TestCloseWebsocketAfterTour(WebsocketCase):
@patch("odoo.tests.common.ChromeBrowser")
def test_ensure_websocket_closed_after_tour(self, mocked_brower_class):
"""Sometimes, Chrome does not close the WebSocket connections properly after
calling `HttpCase@_browser_js`. In such cases, the WebSocket connection
remains open and can interfere with the test cursor, leading to
concurrency errors. To resolve this issue, `HttpCase@_browser_js` ensures
that the WebSocket connections are properly cleaned up. This test ensures
that this behavior is reliable.
"""
websocket_created = False
def navigate_to_side_effect(*args, **kwargs):
self.websocket_connect()
self.assertEqual(len(websocket_module._websocket_instances), 1)
nonlocal websocket_created
websocket_created = True
# Open a socket that won't be closed when calling browser.close()
mocked_brower_class.return_value.navigate_to.side_effect = navigate_to_side_effect
with patch.object(websocket_module, "_websocket_instances", WeakSet()):
self.browser_js("/odoo", "")
self.assertTrue(websocket_created)
# serve_forever_patch prevent websocket instances from being collected. Stop it now.
self._serve_forever_patch.stop()
gc.collect()
self.assertEqual(len(websocket_module._websocket_instances), 0)

View file

@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import odoo
from odoo.tests import HttpCase
@odoo.tests.tagged('-at_install', 'post_install')
class TestGetModelDefinitions(HttpCase):
def test_access_cr(self):
""" Checks that get_model_definitions does not return anything else than models """
with self.assertRaises(KeyError):
self.env['ir.model']._get_model_definitions(['res.users', 'cr'])
def test_access_all_model_fields(self):
"""
Check that get_model_definitions return all the models
and their fields
"""
model_definitions = self.env['ir.model']._get_model_definitions([
'res.users', 'res.partner'
])
# models are retrieved
self.assertIn('res.users', model_definitions)
self.assertIn('res.partner', model_definitions)
# check that model fields are retrieved
self.assertGreaterEqual(model_definitions['res.partner']["fields"].keys(), {'active', 'name', 'user_ids'})
self.assertGreaterEqual(model_definitions['res.partner']["fields"].keys(), {'active', 'name', 'user_ids'})
def test_relational_fields_with_missing_model(self):
"""
Check that get_model_definitions only returns relational fields
if the model is requested
"""
model_definitions = self.env['ir.model']._get_model_definitions([
'res.partner'
])
# since res.country is not requested, country_id shouldn't be in
# the model definition fields
self.assertNotIn('country_id', model_definitions['res.partner']["fields"])
model_definitions = self.env['ir.model']._get_model_definitions([
'res.partner', 'res.country',
])
# res.country is requested, country_id should be present on res.partner
self.assertIn('country_id', model_definitions['res.partner']["fields"])

View file

@ -1,13 +1,38 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import os
import unittest
from odoo.tests import common
from unittest.mock import MagicMock, patch
from odoo.tests import new_test_user, tagged
from .common import WebsocketCase
class TestIrWebsocket(common.HttpCase):
@tagged("-at_install", "post_install")
@unittest.skipIf(os.getenv("ODOO_FAKETIME_TEST_MODE"), "This test cannot work with faketime")
class TestIrWebsocket(WebsocketCase):
def test_only_allow_string_channels_from_frontend(self):
with self.assertRaises(ValueError):
self.env['ir.websocket']._subscribe({
'inactivity_period': 1000,
'last': 0,
'channels': [('odoo', 'mail.channel', 5)],
})
with self.assertLogs("odoo.addons.bus.websocket", level="ERROR") as log:
ws = self.websocket_connect()
self.subscribe(ws, [("odoo", "discuss.channel", 5)], self.env["bus.bus"]._bus_last_id())
self.assertIn("bus.Bus only string channels are allowed.", log.output[0])
def test_build_bus_channel_list(self):
test_user = new_test_user(
self.env, login="test_user", password="Password!1", groups="base.group_system"
)
mock_wsrequest = MagicMock()
mock_wsrequest.session.uid = test_user.id
with patch("odoo.addons.bus.models.ir_websocket.wsrequest", new=mock_wsrequest):
ir_websocket_model = self.env["ir.websocket"].with_user(test_user)
channels = set(ir_websocket_model._build_bus_channel_list(["test_channel"]))
expected_channels = {
"test_channel",
test_user.partner_id,
self.env.ref("base.group_system"),
self.env.ref("base.group_user"),
}
self.assertTrue(
expected_channels.issubset(channels),
f"The channels list is missing some expected values: {expected_channels - channels}.",
)

View file

@ -1,11 +1,16 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.tests import BaseCase
import json
import selectors
import threading
from ..models.bus import json_dump, get_notify_payloads, NOTIFY_PAYLOAD_MAX_LENGTH
import odoo
from odoo.tests import TransactionCase
from ..models.bus import json_dump, get_notify_payloads, NOTIFY_PAYLOAD_MAX_LENGTH, ODOO_NOTIFY_FUNCTION
class NotifyTests(BaseCase):
class NotifyTests(TransactionCase):
def test_get_notify_payloads(self):
"""
@ -47,3 +52,56 @@ class NotifyTests(BaseCase):
"as it contains only 1 channel")
with self.assertRaises(AssertionError):
check_payloads_size(payloads)
def test_postcommit(self):
"""Asserts all ``postcommit`` channels are fetched with a single listen."""
if ODOO_NOTIFY_FUNCTION != "pg_notify":
return
channels = []
stop_event = threading.Event()
selector_ready_event = threading.Event()
def single_listen():
nonlocal channels
with (
odoo.sql_db.db_connect("postgres").cursor() as cr,
selectors.DefaultSelector() as sel,
):
cr.execute("listen imbus")
cr.commit()
conn = cr._cnx
sel.register(conn, selectors.EVENT_READ)
selector_ready_event.set()
found = False
while not stop_event.is_set() and not found:
if sel.select(timeout=5):
conn.poll()
while conn.notifies:
if notify_channels := [
c
for c in json.loads(conn.notifies.pop().payload)
if c[0] == self.env.cr.dbname
]:
channels = notify_channels
found = True
break
thread = threading.Thread(target=single_listen)
thread.start()
selector_ready_event.wait(timeout=5)
self.env["bus.bus"].search([]).unlink()
self.env["bus.bus"]._sendone("channel 1", "test 1", {})
self.env["bus.bus"]._sendone("channel 2", "test 2", {})
self.env["bus.bus"]._sendone("channel 1", "test 3", {})
self.assertEqual(self.env["bus.bus"].search_count([]), 0)
self.assertEqual(channels, [])
self.env.cr.precommit.run() # trigger the creation of bus.bus records
self.assertEqual(self.env["bus.bus"].search_count([]), 3)
self.assertEqual(channels, [])
self.env.cr.postcommit.run() # notify
thread.join(timeout=5)
stop_event.set()
self.assertEqual(self.env["bus.bus"].search_count([]), 3)
self.assertEqual(
channels, [[self.env.cr.dbname, "channel 1"], [self.env.cr.dbname, "channel 2"]]
)

View file

@ -7,25 +7,24 @@ from collections import defaultdict
from datetime import timedelta
from freezegun import freeze_time
from threading import Event
from unittest import skipIf
from unittest.mock import patch
try:
from websocket._exceptions import WebSocketBadStatusException
except ImportError:
pass
from weakref import WeakSet
from odoo import http
from odoo.api import Environment
from odoo.tests import common, new_test_user
from odoo.tools import mute_logger
from .common import WebsocketCase
from .. import websocket as websocket_module
from ..models.bus import dispatch
from ..models.ir_websocket import IrWebsocket
from ..websocket import (
CloseCode,
Frame,
Opcode,
TimeoutManager,
TimeoutReason,
Websocket,
_websocket_instances
WebsocketConnectionHandler,
)
@common.tagged('post_install', '-at_install')
@ -51,18 +50,18 @@ class TestWebsocketCaryall(WebsocketCase):
self.assertEqual(events, ['open', 'close'])
def test_instances_weak_set(self):
gc.collect()
first_ws = self.websocket_connect()
second_ws = self.websocket_connect()
self.assertEqual(len(_websocket_instances), 2)
first_ws.close(CloseCode.CLEAN)
second_ws.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
# serve_forever_patch prevent websocket instances from being
# collected. Stop it now.
self._serve_forever_patch.stop()
gc.collect()
self.assertEqual(len(_websocket_instances), 0)
with patch.object(websocket_module, "_websocket_instances", WeakSet()):
first_ws = self.websocket_connect()
second_ws = self.websocket_connect()
self.assertEqual(len(websocket_module._websocket_instances), 2)
first_ws.close(CloseCode.CLEAN)
second_ws.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
# serve_forever_patch prevent websocket instances from being
# collected. Stop it now.
self._serve_forever_patch.stop()
gc.collect()
self.assertEqual(len(websocket_module._websocket_instances), 0)
def test_timeout_manager_no_response_timeout(self):
with freeze_time('2022-08-19') as frozen_time:
@ -70,46 +69,51 @@ class TestWebsocketCaryall(WebsocketCase):
# A PING frame was just sent, if no pong has been received
# within TIMEOUT seconds, the connection should have timed out.
timeout_manager.acknowledge_frame_sent(Frame(Opcode.PING))
self.assertEqual(timeout_manager._awaited_opcode, Opcode.PONG)
frozen_time.tick(delta=timedelta(seconds=TimeoutManager.TIMEOUT / 2))
self.assertFalse(timeout_manager.has_timed_out())
self.assertFalse(timeout_manager.has_frame_response_timed_out())
frozen_time.tick(delta=timedelta(seconds=TimeoutManager.TIMEOUT / 2))
self.assertTrue(timeout_manager.has_timed_out())
self.assertEqual(timeout_manager.timeout_reason, TimeoutReason.NO_RESPONSE)
self.assertTrue(timeout_manager.has_frame_response_timed_out())
timeout_manager = TimeoutManager()
# A CLOSE frame was just sent, if no close has been received
# within TIMEOUT seconds, the connection should have timed out.
timeout_manager.acknowledge_frame_sent(Frame(Opcode.CLOSE))
self.assertEqual(timeout_manager._awaited_opcode, Opcode.CLOSE)
frozen_time.tick(delta=timedelta(seconds=TimeoutManager.TIMEOUT / 2))
self.assertFalse(timeout_manager.has_timed_out())
self.assertFalse(timeout_manager.has_frame_response_timed_out())
frozen_time.tick(delta=timedelta(seconds=TimeoutManager.TIMEOUT / 2))
self.assertTrue(timeout_manager.has_timed_out())
self.assertEqual(timeout_manager.timeout_reason, TimeoutReason.NO_RESPONSE)
self.assertTrue(timeout_manager.has_frame_response_timed_out())
def test_timeout_manager_overlapping_timeouts(self):
with freeze_time('2022-08-19') as frozen_time:
timeout_manager = TimeoutManager()
timeout_manager.acknowledge_frame_sent(Frame(Opcode.CLOSE))
timeout_manager.acknowledge_frame_sent(Frame(Opcode.PING))
timeout_manager.acknowledge_frame_receipt(Frame(Opcode.PONG))
frozen_time.tick(delta=timedelta(seconds=timeout_manager.TIMEOUT + 1))
self.assertTrue(timeout_manager.has_frame_response_timed_out())
def test_timeout_manager_keep_alive_timeout(self):
with freeze_time('2022-08-19') as frozen_time:
timeout_manager = TimeoutManager()
frozen_time.tick(delta=timedelta(seconds=timeout_manager._keep_alive_timeout / 2))
self.assertFalse(timeout_manager.has_timed_out())
self.assertFalse(timeout_manager.has_keep_alive_timed_out())
frozen_time.tick(delta=timedelta(seconds=timeout_manager._keep_alive_timeout / 2 + 1))
self.assertTrue(timeout_manager.has_timed_out())
self.assertEqual(timeout_manager.timeout_reason, TimeoutReason.KEEP_ALIVE)
self.assertTrue(timeout_manager.has_keep_alive_timed_out())
def test_timeout_manager_reset_wait_for(self):
timeout_manager = TimeoutManager()
# PING frame
timeout_manager.acknowledge_frame_sent(Frame(Opcode.PING))
self.assertEqual(timeout_manager._awaited_opcode, Opcode.PONG)
timeout_manager.acknowledge_frame_receipt(Frame(Opcode.PONG))
self.assertIsNone(timeout_manager._awaited_opcode)
with freeze_time('2022-08-19') as frozen_time:
timeout_manager = TimeoutManager()
# PING frame
timeout_manager.acknowledge_frame_sent(Frame(Opcode.PING))
timeout_manager.acknowledge_frame_receipt(Frame(Opcode.PONG))
frozen_time.tick(delta=timedelta(seconds=timeout_manager.TIMEOUT + 1))
self.assertFalse(timeout_manager.has_frame_response_timed_out())
# CLOSE frame
timeout_manager.acknowledge_frame_sent(Frame(Opcode.CLOSE))
self.assertEqual(timeout_manager._awaited_opcode, Opcode.CLOSE)
timeout_manager.acknowledge_frame_receipt(Frame(Opcode.CLOSE))
self.assertIsNone(timeout_manager._awaited_opcode)
# CLOSE frame
timeout_manager.acknowledge_frame_sent(Frame(Opcode.CLOSE))
timeout_manager.acknowledge_frame_receipt(Frame(Opcode.CLOSE))
frozen_time.tick(delta=timedelta(seconds=timeout_manager.TIMEOUT + 1))
self.assertFalse(timeout_manager.has_frame_response_timed_out())
def test_user_login(self):
websocket = self.websocket_connect()
@ -118,7 +122,7 @@ class TestWebsocketCaryall(WebsocketCase):
# The session with whom the websocket connected has been
# deleted. WebSocket should disconnect in order for the
# session to be updated.
websocket.send(json.dumps({'event_name': 'subscribe'}))
self.subscribe(websocket, wait_for_dispatch=False)
self.assert_close_with_code(websocket, CloseCode.SESSION_EXPIRED)
def test_user_logout_incoming_message(self):
@ -129,150 +133,213 @@ class TestWebsocketCaryall(WebsocketCase):
# The session with whom the websocket connected has been
# deleted. WebSocket should disconnect in order for the
# session to be updated.
websocket.send(json.dumps({'event_name': 'subscribe'}))
self.subscribe(websocket, wait_for_dispatch=False)
self.assert_close_with_code(websocket, CloseCode.SESSION_EXPIRED)
@skipIf(os.getenv("ODOO_FAKETIME_TEST_MODE"), 'This test times out when faketime is used')
def test_user_logout_outgoing_message(self):
odoo_ws = None
def patched_subscribe(self, *args):
nonlocal odoo_ws
odoo_ws = self
new_test_user(self.env, login='test_user', password='Password!1')
user_session = self.authenticate('test_user', 'Password!1')
websocket = self.websocket_connect(cookie=f'session_id={user_session.sid};')
with patch.object(Websocket, 'subscribe', patched_subscribe):
self.subscribe(
websocket,
["channel1"],
self.env["bus.bus"].search([], limit=1, order="id DESC").id or 0,
)
self.url_open('/web/session/logout')
# Simulate postgres notify. The session with whom the websocket
# connected has been deleted. WebSocket should be closed without
# receiving the message.
self.env['bus.bus']._sendone('channel1', 'notif type', 'message')
odoo_ws.trigger_notification_dispatching()
self.assert_close_with_code(websocket, CloseCode.SESSION_EXPIRED)
self.subscribe(websocket, ['channel1'], self.env['bus.bus']._bus_last_id())
self.url_open('/web/session/logout')
# Simulate postgres notify. The session with whom the websocket
# connected has been deleted. WebSocket should be closed without
# receiving the message.
self.env['bus.bus']._sendone('channel1', 'notif type', 'message')
self.trigger_notification_dispatching(["channel1"])
self.assert_close_with_code(websocket, CloseCode.SESSION_EXPIRED)
def test_channel_subscription_disconnect(self):
subscribe_done_event = Event()
original_subscribe = dispatch.subscribe
def patched_subscribe(*args):
original_subscribe(*args)
subscribe_done_event.set()
with patch.object(dispatch, 'subscribe', patched_subscribe):
websocket = self.websocket_connect()
websocket.send(json.dumps({
'event_name': 'subscribe',
'data': {'channels': ['my_channel'], 'last': 0}
}))
subscribe_done_event.wait(timeout=5)
# channel is added as expected to the channel to websocket map.
self.assertIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
websocket.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
# channel is removed as expected when removing the last
# websocket that was listening to this channel.
self.assertNotIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
websocket = self.websocket_connect()
self.subscribe(websocket, ['my_channel'], self.env['bus.bus']._bus_last_id())
# channel is added as expected to the channel to websocket map.
self.assertIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
websocket.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
# channel is removed as expected when removing the last
# websocket that was listening to this channel.
self.assertNotIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
def test_channel_subscription_update(self):
subscribe_done_event = Event()
original_subscribe = dispatch.subscribe
def patched_subscribe(*args):
original_subscribe(*args)
subscribe_done_event.set()
with patch.object(dispatch, 'subscribe', patched_subscribe):
websocket = self.websocket_connect()
websocket.send(json.dumps({
'event_name': 'subscribe',
'data': {'channels': ['my_channel'], 'last': 0}
}))
subscribe_done_event.wait(timeout=5)
subscribe_done_event.clear()
# channel is added as expected to the channel to websocket map.
self.assertIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
websocket.send(json.dumps({
'event_name': 'subscribe',
'data': {'channels': ['my_channel_2'], 'last': 0}
}))
subscribe_done_event.wait(timeout=5)
# channel is removed as expected when updating the subscription.
self.assertNotIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
websocket = self.websocket_connect()
self.subscribe(websocket, ['my_channel'], self.env['bus.bus']._bus_last_id())
# channel is added as expected to the channel to websocket map.
self.assertIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
self.subscribe(websocket, ['my_channel_2'], self.env['bus.bus']._bus_last_id())
# channel is removed as expected when updating the subscription.
self.assertNotIn((self.env.registry.db_name, 'my_channel'), dispatch._channels_to_ws)
def test_trigger_notification(self):
original_subscribe = Websocket.subscribe
odoo_ws = None
websocket = self.websocket_connect()
self.subscribe(websocket, ['my_channel'], self.env['bus.bus']._bus_last_id())
self.env['bus.bus']._sendone('my_channel', 'notif_type', 'message')
self.trigger_notification_dispatching(["my_channel"])
notifications = json.loads(websocket.recv())
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]['message']['type'], 'notif_type')
self.assertEqual(notifications[0]['message']['payload'], 'message')
self.env['bus.bus']._sendone('my_channel', 'notif_type', 'another_message')
self.trigger_notification_dispatching(["my_channel"])
notifications = json.loads(websocket.recv())
# First notification has been received, we should only receive
# the second one.
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]['message']['type'], 'notif_type')
self.assertEqual(notifications[0]['message']['payload'], 'another_message')
def patched_subscribe(self, *args):
nonlocal odoo_ws
odoo_ws = self
original_subscribe(self, *args)
with patch.object(Websocket, 'subscribe', patched_subscribe):
websocket = self.websocket_connect()
bus_last_id = self.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
self.env['bus.bus']._sendone('my_channel', 'notif_type', 'message')
self.subscribe(websocket, ["my_channel"], bus_last_id)
notifications = json.loads(websocket.recv())
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]['message']['type'], 'notif_type')
self.assertEqual(notifications[0]['message']['payload'], 'message')
self.env['bus.bus']._sendone('my_channel', 'notif_type', 'another_message')
odoo_ws.trigger_notification_dispatching()
notifications = json.loads(websocket.recv())
# First notification has been received, we should only receive
# the second one.
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]['message']['type'], 'notif_type')
self.assertEqual(notifications[0]['message']['payload'], 'another_message')
def test_trigger_notification_unsupported_language(self):
websocket = self.websocket_connect()
# set session lang to what a websitor visitor could have (based on their
# preferred language), this could be a unknown language (ex. territorial
# specific) or a known language that is uninstalled; in all cases this
# should not crash the notif. dispatching.
self.session.context['lang'] = 'fr_LU'
http.root.session_store.save(self.session)
self.subscribe(websocket, ['my_channel'], self.env['bus.bus']._bus_last_id())
self.env['bus.bus']._sendone('my_channel', 'notif_type', 'message')
self.trigger_notification_dispatching(["my_channel"])
notifications = json.loads(websocket.recv())
self.assertEqual(1, len(notifications))
self.assertEqual(notifications[0]['message']['type'], 'notif_type')
self.assertEqual(notifications[0]['message']['payload'], 'message')
def test_subscribe_higher_last_notification_id(self):
subscribe_done_event = Event()
server_last_notification_id = self.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
client_last_notification_id = server_last_notification_id + 1
def subscribe_side_effect(_, last):
# Last notification id given by the client is higher than
# the one known by the server, should default to 0.
self.assertEqual(last, 0)
subscribe_done_event.set()
with patch.object(Websocket, 'subscribe', side_effect=subscribe_side_effect):
with patch.object(Websocket, 'subscribe', side_effect=Websocket.subscribe, autospec=True) as mock:
websocket = self.websocket_connect()
websocket.send(json.dumps({
'event_name': 'subscribe',
'data': {'channels': ['my_channel'], 'last': client_last_notification_id}
}))
subscribe_done_event.wait()
self.subscribe(websocket, ['my_channel'], client_last_notification_id)
self.assertEqual(mock.call_args[0][2], 0)
def test_subscribe_lower_last_notification_id(self):
subscribe_done_event = Event()
server_last_notification_id = self.env['bus.bus'].sudo().search([], limit=1, order='id desc').id or 0
client_last_notification_id = server_last_notification_id - 1
def subscribe_side_effect(_, last):
self.assertEqual(last, client_last_notification_id)
subscribe_done_event.set()
with patch.object(Websocket, 'subscribe', side_effect=subscribe_side_effect):
with patch.object(Websocket, 'subscribe', side_effect=Websocket.subscribe, autospec=True) as mock:
websocket = self.websocket_connect()
websocket.send(json.dumps({
'event_name': 'subscribe',
'data': {'channels': ['my_channel'], 'last': client_last_notification_id}
}))
subscribe_done_event.wait()
self.subscribe(websocket, ['my_channel'], client_last_notification_id)
self.assertEqual(mock.call_args[0][2], client_last_notification_id)
def test_subscribe_to_custom_channel(self):
channel = self.env["res.partner"].create({"name": "John"})
websocket = self.websocket_connect()
with patch.object(IrWebsocket, "_build_bus_channel_list", return_value=[channel]):
self.subscribe(websocket, [], self.env['bus.bus']._bus_last_id())
channel._bus_send("notif_on_global_channel", "message")
channel._bus_send("notif_on_private_channel", "message", subchannel="PRIVATE")
self.trigger_notification_dispatching([channel, (channel, "PRIVATE")])
notifications = json.loads(websocket.recv())
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0]['message']['type'], 'notif_on_global_channel')
self.assertEqual(notifications[0]['message']['payload'], 'message')
with patch.object(IrWebsocket, "_build_bus_channel_list", return_value=[(channel, "PRIVATE")]):
self.subscribe(websocket, [], self.env['bus.bus']._bus_last_id())
channel._bus_send("notif_on_global_channel", "message")
channel._bus_send("notif_on_private_channel", "message", subchannel="PRIVATE")
self.trigger_notification_dispatching([channel, (channel, "PRIVATE")])
notifications = json.loads(websocket.recv())
self.assertEqual(len(notifications), 1)
self.assertEqual(notifications[0]['message']['type'], 'notif_on_private_channel')
self.assertEqual(notifications[0]['message']['payload'], 'message')
def test_no_cursor_when_no_callback_for_lifecycle_event(self):
with patch.object(Websocket, '_Websocket__event_callbacks', defaultdict(set)):
with patch('odoo.addons.bus.websocket.acquire_cursor') as mock:
self.websocket_connect()
self.assertFalse(mock.called)
@patch.dict(os.environ, {"ODOO_BUS_PUBLIC_SAMESITE_WS": "True"})
def test_public_configuration(self):
new_test_user(self.env, login='test_user', password='Password!1')
user_session = self.authenticate('test_user', 'Password!1')
serve_forever_called_event = Event()
original_serve_forever = WebsocketConnectionHandler._serve_forever
def serve_forever(websocket, *args):
original_serve_forever(websocket, *args)
self.assertNotEqual(websocket._session.sid, user_session.sid)
self.assertNotEqual(websocket._session.uid, user_session.uid)
serve_forever_called_event.set()
with patch.object(
WebsocketConnectionHandler, '_serve_forever', side_effect=serve_forever
) as mock, mute_logger('odoo.addons.bus.websocket'):
ws = self.websocket_connect(
cookie=f'session_id={user_session.sid};',
origin="http://example.com"
)
self.assertTrue(
ws.getheaders().get('set-cookie').startswith(f'session_id={user_session.sid}'),
'The set-cookie response header must be the origin request session rather than the websocket session'
)
serve_forever_called_event.wait(timeout=5)
self.assertTrue(mock.called)
def test_trigger_on_websocket_closed(self):
with patch('odoo.addons.bus.models.ir_websocket.IrWebsocket._on_websocket_closed') as mock:
ws = self.websocket_connect()
ws.close(CloseCode.CLEAN)
self.wait_remaining_websocket_connections()
self.assertTrue(mock.called)
def test_disconnect_when_version_outdated(self):
# Outdated version, connection should be closed immediately
with patch.object(WebsocketConnectionHandler, "_VERSION", "17.0-1"), patch.object(
self, "_WEBSOCKET_URL", f"{self._BASE_WEBSOCKET_URL}?version=17.0-0"
):
websocket = self.websocket_connect(
ping_after_connect=False, header={"User-Agent": "Chrome/126.0.0.0"}
)
self.assert_close_with_code(websocket, CloseCode.CLEAN, "OUTDATED_VERSION")
# Version not passed, User-Agent present, should be considered as outdated
with patch.object(WebsocketConnectionHandler, "_VERSION", "17.0-1"), patch.object(
self, "_WEBSOCKET_URL", self._BASE_WEBSOCKET_URL
):
websocket = self.websocket_connect(
ping_after_connect=False, header={"User-Agent": "Chrome/126.0.0.0"}
)
self.assert_close_with_code(websocket, CloseCode.CLEAN, "OUTDATED_VERSION")
# Version not passed, User-Agent not present, should not be considered
# as outdated
with patch.object(WebsocketConnectionHandler, "_VERSION", "17.0-1"), patch.object(
self, "_WEBSOCKET_URL", self._BASE_WEBSOCKET_URL
):
websocket = self.websocket_connect()
websocket.ping()
websocket.recv_data_frame(control_frame=True) # pong
def test_websocket_terminates_after_closing_timeout(self):
orig_disconnect = Websocket._disconnect
orig_terminate = Websocket._terminate
disconnect_done_event = Event()
terminate_done_event = Event()
def disconnect_wrapper(self, code):
orig_disconnect(self, code)
disconnect_done_event.set()
def terminate_wrapper(self):
orig_terminate(self)
terminate_done_event.set()
with (
patch('odoo.addons.bus.websocket.TimeoutManager.KEEP_ALIVE_TIMEOUT', 0),
patch.object(Websocket, '_disconnect', disconnect_wrapper),
patch.object(Websocket, '_terminate', terminate_wrapper),
freeze_time('2022-08-19') as frozen_time,
):
ws = self.websocket_connect(ping_after_connect=False)
ws.send(b'\x00') # Wake up the WebSocket loop.
self.assertTrue(
disconnect_done_event.wait(timeout=5),
'Server should have initiated the closing handshake as the keep alive timeout is exceeded.',
)
frozen_time.tick(delta=timedelta(seconds=TimeoutManager.TIMEOUT + 1))
ws.send(b'\x00') # Wake up the WebSocket loop.
self.assertTrue(
terminate_done_event.wait(timeout=5),
'Server should have terminated the connection as it didn\'t receive any response.',
)

View file

@ -1,33 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from odoo.http import root, SESSION_ROTATION_INTERVAL
from odoo.tests import JsonRpcException
from odoo.addons.base.tests.common import HttpCaseWithUserDemo
class TestWebsocketController(HttpCaseWithUserDemo):
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()
)
result = self.make_jsonrpc_request('/websocket/peek_notifications', {
'channels': [],
'last': 0,
'is_first_poll': True,
})
# 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)
@ -36,20 +23,18 @@ class TestWebsocketController(HttpCaseWithUserDemo):
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()
)
result = self.make_jsonrpc_request('/websocket/peek_notifications', {
'channels': [],
'last': 0,
'is_first_poll': False,
})
# Reponse is received as long as the session is valid.
self.assertIn('result', response)
self.assertIsNotNone(result)
def test_websocket_peek_session_expired_login(self):
session = self.authenticate(None, None)
# first rpc should be fine
self._make_rpc('/websocket/peek_notifications', {
self.make_jsonrpc_request('/websocket/peek_notifications', {
'channels': [],
'last': 0,
'is_first_poll': True,
@ -57,36 +42,42 @@ class TestWebsocketController(HttpCaseWithUserDemo):
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', {
with self.assertRaises(JsonRpcException, msg='odoo.http.SessionExpiredException'):
self.make_jsonrpc_request('/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')
self.authenticate('demo', 'demo')
# first rpc should be fine
self._make_rpc('/websocket/peek_notifications', {
self.make_jsonrpc_request('/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', {
with self.assertRaises(JsonRpcException, msg='odoo.http.SessionExpiredException'):
self.make_jsonrpc_request('/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_do_not_rotate_session_when_peeking_notifications(self):
self.authenticate('admin', 'admin')
self.url_open('/odoo')
original_session = self.opener.cookies['session_id']
original_session_obj = root.session_store.get(original_session)
original_session_obj['create_time'] -= SESSION_ROTATION_INTERVAL
root.session_store.save(original_session_obj)
self.make_jsonrpc_request('/websocket/peek_notifications', {
'channels': [],
'last': 0,
'is_first_poll': True,
})
self.assertEqual(self.opener.cookies['session_id'], original_session)
self.url_open("/odoo")
self.assertNotEqual(self.opener.cookies['session_id'], original_session)

View file

@ -22,8 +22,10 @@ class TestWebsocketRateLimiting(WebsocketCase):
time.sleep(Websocket.RL_DELAY)
for _ in range(Websocket.RL_BURST + 1):
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
time.sleep(Websocket.RL_DELAY)
ws.send(json.dumps({"event_name": "test_rate_limiting"}))
time.sleep(Websocket.RL_DELAY * 1.25)
self.assertTrue(ws.connected)
def test_rate_limiting_base_ko(self):
def check_base_ko():