mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-21 07:52:07 +02:00
19.0 vanilla
This commit is contained in:
parent
d1963a3c3a
commit
2d3ee4855a
7430 changed files with 2687981 additions and 2965473 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
36
odoo-bringout-oca-ocb-bus/bus/tests/test_bus_gc.py
Normal file
36
odoo-bringout-oca-ocb-bus/bus/tests/test_bus_gc.py
Normal 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)
|
||||
|
|
@ -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)
|
||||
46
odoo-bringout-oca-ocb-bus/bus/tests/test_ir_model.py
Normal file
46
odoo-bringout-oca-ocb-bus/bus/tests/test_ir_model.py
Normal 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"])
|
||||
|
|
@ -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}.",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue