mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-22 04:52:02 +02:00
Initial commit: Core packages
This commit is contained in:
commit
12c29a983b
9512 changed files with 8379910 additions and 0 deletions
8
odoo-bringout-oca-ocb-bus/bus/tests/__init__.py
Normal file
8
odoo-bringout-oca-ocb-bus/bus/tests/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
from . import common
|
||||
from . import test_assetsbundle
|
||||
from . import test_health
|
||||
from . import test_ir_websocket
|
||||
from . import test_notify
|
||||
from . import test_websocket_caryall
|
||||
from . import test_websocket_controller
|
||||
from . import test_websocket_rate_limiting
|
||||
124
odoo-bringout-oca-ocb-bus/bus/tests/common.py
Normal file
124
odoo-bringout-oca-ocb-bus/bus/tests/common.py
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import struct
|
||||
from threading import Event
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
try:
|
||||
import websocket
|
||||
except ImportError:
|
||||
websocket = None
|
||||
|
||||
import odoo.tools
|
||||
from odoo.tests import HOST, HttpCase, TEST_CURSOR_COOKIE_NAME
|
||||
from ..websocket import CloseCode, Websocket, WebsocketConnectionHandler
|
||||
|
||||
|
||||
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"
|
||||
websocket_allowed_patch = patch.object(WebsocketConnectionHandler, "websocket_allowed", return_value=True)
|
||||
cls.startClassPatcher(websocket_allowed_patch)
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self._websockets = set()
|
||||
# Used to ensure websocket connections have been closed
|
||||
# properly.
|
||||
self._websocket_events = set()
|
||||
original_serve_forever = WebsocketConnectionHandler._serve_forever
|
||||
|
||||
def _mocked_serve_forever(*args):
|
||||
websocket_closed_event = Event()
|
||||
self._websocket_events.add(websocket_closed_event)
|
||||
original_serve_forever(*args)
|
||||
websocket_closed_event.set()
|
||||
|
||||
self._serve_forever_patch = patch.object(
|
||||
WebsocketConnectionHandler,
|
||||
'_serve_forever',
|
||||
wraps=_mocked_serve_forever
|
||||
)
|
||||
self.startPatcher(self._serve_forever_patch)
|
||||
|
||||
def tearDown(self):
|
||||
self._close_websockets()
|
||||
super().tearDown()
|
||||
|
||||
def _close_websockets(self):
|
||||
"""
|
||||
Close all the connected websockets and wait for the connection
|
||||
to terminate.
|
||||
"""
|
||||
for ws in self._websockets:
|
||||
if ws.connected:
|
||||
ws.close(CloseCode.CLEAN)
|
||||
self.wait_remaining_websocket_connections()
|
||||
|
||||
def websocket_connect(self, *args, **kwargs):
|
||||
"""
|
||||
Connect a websocket. If no cookie is given, the connection is
|
||||
opened with a default session. The created websocket is closed
|
||||
at the end of the test.
|
||||
"""
|
||||
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
|
||||
ws = websocket.create_connection(
|
||||
type(self)._WEBSOCKET_URL, *args, **kwargs
|
||||
)
|
||||
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.
|
||||
:param wait_for_dispatch: Whether to wait for the notification
|
||||
dispatching trigerred by the subscription.
|
||||
"""
|
||||
dispatch_bus_notification_done = Event()
|
||||
original_dispatch_bus_notifications = Websocket._dispatch_bus_notifications
|
||||
|
||||
def _mocked_dispatch_bus_notifications(self, *args):
|
||||
original_dispatch_bus_notifications(self, *args)
|
||||
dispatch_bus_notification_done.set()
|
||||
|
||||
with patch.object(Websocket, '_dispatch_bus_notifications', _mocked_dispatch_bus_notifications):
|
||||
sub = {'event_name': 'subscribe', 'data': {
|
||||
'channels': channels or [],
|
||||
}}
|
||||
if last:
|
||||
sub['data']['last'] = last
|
||||
websocket.send(json.dumps(sub))
|
||||
if wait_for_dispatch:
|
||||
dispatch_bus_notification_done.wait(timeout=5)
|
||||
|
||||
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):
|
||||
"""
|
||||
Assert that the websocket is closed with the expected_code.
|
||||
"""
|
||||
opcode, payload = websocket.recv_data()
|
||||
# ensure it's a close frame
|
||||
self.assertEqual(opcode, 8)
|
||||
code = struct.unpack('!H', payload[:2])[0]
|
||||
# ensure the close code is the one we expected
|
||||
self.assertEqual(code, expected_code)
|
||||
51
odoo-bringout-oca-ocb-bus/bus/tests/test_assetsbundle.py
Normal file
51
odoo-bringout-oca-ocb-bus/bus/tests/test_assetsbundle.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# -*- 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')
|
||||
class BusWebTests(odoo.tests.HttpCase):
|
||||
|
||||
def test_bundle_sends_bus(self):
|
||||
"""
|
||||
Tests two things:
|
||||
- Messages are posted to the bus when assets change
|
||||
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()
|
||||
|
||||
sendones = []
|
||||
def patched_sendone(self, channel, notificationType, message):
|
||||
""" Control API and number of messages posted to the bus linked to
|
||||
bundle_changed events """
|
||||
if notificationType == 'bundle_changed':
|
||||
sendones.append((channel, message))
|
||||
|
||||
self.patch(type(self.env['bus.bus']), '_sendone', patched_sendone)
|
||||
|
||||
self.authenticate('admin', 'admin')
|
||||
self.url_open('/web')
|
||||
|
||||
# One sendone for each asset bundle and for each CSS / JS
|
||||
self.assertEqual(
|
||||
len(sendones),
|
||||
4,
|
||||
'Received %s' % '\n'.join('%s - %s' % (tmp[0], tmp[1]) for tmp in sendones)
|
||||
)
|
||||
for (channel, message) in sendones:
|
||||
self.assertEqual(channel, 'broadcast')
|
||||
self.assertEqual(len(message), 1)
|
||||
self.assertTrue(isinstance(message.get('server_version'), str))
|
||||
12
odoo-bringout-oca-ocb-bus/bus/tests/test_health.py
Normal file
12
odoo-bringout-oca-ocb-bus/bus/tests/test_health.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import HttpCase
|
||||
|
||||
|
||||
class TestBusController(HttpCase):
|
||||
def test_health(self):
|
||||
response = self.url_open('/websocket/health')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
payload = response.json()
|
||||
self.assertEqual(payload['status'], 'pass')
|
||||
self.assertFalse(response.cookies.get('session_id'))
|
||||
13
odoo-bringout-oca-ocb-bus/bus/tests/test_ir_websocket.py
Normal file
13
odoo-bringout-oca-ocb-bus/bus/tests/test_ir_websocket.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import common
|
||||
|
||||
|
||||
class TestIrWebsocket(common.HttpCase):
|
||||
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)],
|
||||
})
|
||||
49
odoo-bringout-oca-ocb-bus/bus/tests/test_notify.py
Normal file
49
odoo-bringout-oca-ocb-bus/bus/tests/test_notify.py
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.tests import BaseCase
|
||||
|
||||
from ..models.bus import json_dump, get_notify_payloads, NOTIFY_PAYLOAD_MAX_LENGTH
|
||||
|
||||
|
||||
class NotifyTests(BaseCase):
|
||||
|
||||
def test_get_notify_payloads(self):
|
||||
"""
|
||||
Asserts that the implementation of `get_notify_payloads`
|
||||
actually splits correctly large payloads
|
||||
"""
|
||||
def check_payloads_size(payloads):
|
||||
for payload in payloads:
|
||||
self.assertLess(len(payload.encode()), NOTIFY_PAYLOAD_MAX_LENGTH)
|
||||
|
||||
channel = ('dummy_db', 'dummy_model', 12345)
|
||||
channels = [channel]
|
||||
self.assertLess(len(json_dump(channels).encode()), NOTIFY_PAYLOAD_MAX_LENGTH)
|
||||
payloads = get_notify_payloads(channels)
|
||||
self.assertEqual(len(payloads), 1,
|
||||
"The payload is less then the threshold, "
|
||||
"there should be 1 payload only, as it shouldn't be split")
|
||||
channels = [channel] * 100
|
||||
self.assertLess(len(json_dump(channels).encode()), NOTIFY_PAYLOAD_MAX_LENGTH)
|
||||
payloads = get_notify_payloads(channels)
|
||||
self.assertEqual(len(payloads), 1,
|
||||
"The payload is less then the threshold, "
|
||||
"there should be 1 payload only, as it shouldn't be split")
|
||||
check_payloads_size(payloads)
|
||||
channels = [channel] * 1000
|
||||
self.assertGreaterEqual(len(json_dump(channels).encode()), NOTIFY_PAYLOAD_MAX_LENGTH)
|
||||
payloads = get_notify_payloads(channels)
|
||||
self.assertGreater(len(payloads), 1,
|
||||
"Payload was larger than the threshold, it should've been split")
|
||||
check_payloads_size(payloads)
|
||||
|
||||
fat_channel = tuple(item * 1000 for item in channel)
|
||||
channels = [fat_channel]
|
||||
self.assertEqual(len(channels), 1, "There should be only 1 channel")
|
||||
self.assertGreaterEqual(len(json_dump(channels).encode()), NOTIFY_PAYLOAD_MAX_LENGTH)
|
||||
payloads = get_notify_payloads(channels)
|
||||
self.assertEqual(len(payloads), 1,
|
||||
"Payload was larger than the threshold, but shouldn't be split, "
|
||||
"as it contains only 1 channel")
|
||||
with self.assertRaises(AssertionError):
|
||||
check_payloads_size(payloads)
|
||||
278
odoo-bringout-oca-ocb-bus/bus/tests/test_websocket_caryall.py
Normal file
278
odoo-bringout-oca-ocb-bus/bus/tests/test_websocket_caryall.py
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import gc
|
||||
import json
|
||||
import os
|
||||
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 odoo.api import Environment
|
||||
from odoo.tests import common, new_test_user
|
||||
from .common import WebsocketCase
|
||||
from ..models.bus import dispatch
|
||||
from ..websocket import (
|
||||
CloseCode,
|
||||
Frame,
|
||||
Opcode,
|
||||
TimeoutManager,
|
||||
TimeoutReason,
|
||||
Websocket,
|
||||
_websocket_instances
|
||||
)
|
||||
|
||||
@common.tagged('post_install', '-at_install')
|
||||
class TestWebsocketCaryall(WebsocketCase):
|
||||
def test_lifecycle_hooks(self):
|
||||
events = []
|
||||
with patch.object(Websocket, '_Websocket__event_callbacks', defaultdict(set)):
|
||||
@Websocket.onopen
|
||||
def onopen(env, websocket): # pylint: disable=unused-variable
|
||||
self.assertIsInstance(env, Environment)
|
||||
self.assertIsInstance(websocket, Websocket)
|
||||
events.append('open')
|
||||
|
||||
@Websocket.onclose
|
||||
def onclose(env, websocket): # pylint: disable=unused-variable
|
||||
self.assertIsInstance(env, Environment)
|
||||
self.assertIsInstance(websocket, Websocket)
|
||||
events.append('close')
|
||||
|
||||
ws = self.websocket_connect()
|
||||
ws.close(CloseCode.CLEAN)
|
||||
self.wait_remaining_websocket_connections()
|
||||
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)
|
||||
|
||||
def test_timeout_manager_no_response_timeout(self):
|
||||
with freeze_time('2022-08-19') as frozen_time:
|
||||
timeout_manager = TimeoutManager()
|
||||
# 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())
|
||||
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)
|
||||
|
||||
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())
|
||||
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)
|
||||
|
||||
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())
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
def test_user_login(self):
|
||||
websocket = self.websocket_connect()
|
||||
new_test_user(self.env, login='test_user', password='Password!1')
|
||||
self.authenticate('test_user', 'Password!1')
|
||||
# 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.assert_close_with_code(websocket, CloseCode.SESSION_EXPIRED)
|
||||
|
||||
def test_user_logout_incoming_message(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};')
|
||||
self.url_open('/web/session/logout')
|
||||
# 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.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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def test_trigger_notification(self):
|
||||
original_subscribe = Websocket.subscribe
|
||||
odoo_ws = None
|
||||
|
||||
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_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):
|
||||
websocket = self.websocket_connect()
|
||||
websocket.send(json.dumps({
|
||||
'event_name': 'subscribe',
|
||||
'data': {'channels': ['my_channel'], 'last': client_last_notification_id}
|
||||
}))
|
||||
subscribe_done_event.wait()
|
||||
|
||||
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):
|
||||
websocket = self.websocket_connect()
|
||||
websocket.send(json.dumps({
|
||||
'event_name': 'subscribe',
|
||||
'data': {'channels': ['my_channel'], 'last': client_last_notification_id}
|
||||
}))
|
||||
subscribe_done_event.wait()
|
||||
|
||||
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)
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
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()
|
||||
)
|
||||
# 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'])
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
try:
|
||||
from websocket._exceptions import WebSocketProtocolException
|
||||
from websocket._abnf import VALID_CLOSE_STATUS
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from odoo.tests import common
|
||||
from .common import WebsocketCase
|
||||
from ..websocket import CloseCode, Websocket
|
||||
|
||||
@common.tagged('post_install', '-at_install')
|
||||
class TestWebsocketRateLimiting(WebsocketCase):
|
||||
def test_rate_limiting_base_ok(self):
|
||||
ws = self.websocket_connect()
|
||||
|
||||
# slepeing after initial ping frame
|
||||
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)
|
||||
|
||||
def test_rate_limiting_base_ko(self):
|
||||
def check_base_ko():
|
||||
for _ in range(Websocket.RL_BURST + 1):
|
||||
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
|
||||
self.assert_close_with_code(ws, CloseCode.TRY_LATER)
|
||||
|
||||
ws = self.websocket_connect()
|
||||
|
||||
if 1013 not in VALID_CLOSE_STATUS:
|
||||
# Websocket client's close codes are not up to date. Indeed, the
|
||||
# 1013 close code results in a protocol exception while it is a
|
||||
# valid, registered close code ("TRY LATER") :
|
||||
# https://www.iana.org/assignments/websocket/websocket.xhtml
|
||||
with self.assertRaises(WebSocketProtocolException) as cm:
|
||||
check_base_ko()
|
||||
self.assertEqual(str(cm.exception), 'Invalid close opcode.')
|
||||
else:
|
||||
check_base_ko()
|
||||
|
||||
def test_rate_limiting_opening_burst(self):
|
||||
ws = self.websocket_connect()
|
||||
|
||||
# slepeing after initial ping frame
|
||||
time.sleep(Websocket.RL_DELAY)
|
||||
|
||||
# burst is allowed
|
||||
for _ in range(Websocket.RL_BURST // 2):
|
||||
ws.send(json.dumps({"event_name": "test_rate_limiting"}))
|
||||
|
||||
# as long as the rate is respected afterwards
|
||||
for _ in range(Websocket.RL_BURST):
|
||||
time.sleep(Websocket.RL_DELAY * 2)
|
||||
ws.send(json.dumps({"event_name": "test_rate_limiting"}))
|
||||
|
||||
def test_rate_limiting_start_ok_end_ko(self):
|
||||
def check_end_ko():
|
||||
# those requests are illicit and should not be accepted.
|
||||
for _ in range(Websocket.RL_BURST * 2):
|
||||
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
|
||||
self.assert_close_with_code(ws, CloseCode.TRY_LATER)
|
||||
|
||||
ws = self.websocket_connect()
|
||||
|
||||
# first requests are legit and should be accepted
|
||||
for _ in range(Websocket.RL_BURST + 1):
|
||||
ws.send(json.dumps({'event_name': 'test_rate_limiting'}))
|
||||
time.sleep(Websocket.RL_DELAY)
|
||||
|
||||
if 1013 not in VALID_CLOSE_STATUS:
|
||||
# Websocket client's close codes are not up to date. Indeed, the
|
||||
# 1013 close code results in a protocol exception while it is a
|
||||
# valid, registered close code ("TRY LATER") :
|
||||
# https://www.iana.org/assignments/websocket/websocket.xhtml
|
||||
with self.assertRaises(WebSocketProtocolException) as cm:
|
||||
check_end_ko()
|
||||
self.assertEqual(str(cm.exception), 'Invalid close opcode.')
|
||||
else:
|
||||
check_end_ko()
|
||||
Loading…
Add table
Add a link
Reference in a new issue