# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import json import socket from datetime import datetime import odoo from odoo.tools.misc import mute_logger from odoo.addons.mail.models.partner_devices import InvalidVapidError from odoo.addons.mail.tests.common import mail_new_test_user from odoo.addons.mail.web_push import ENCRYPTION_BLOCK_OVERHEAD, ENCRYPTION_HEADER_SIZE from odoo.addons.sms.tests.common import SMSCommon from odoo.addons.test_mail.data.test_mail_data import MAIL_TEMPLATE from odoo.tests import tagged from markupsafe import Markup from unittest.mock import patch from types import SimpleNamespace @tagged('post_install', '-at_install', 'mail_push') class TestWebPushNotification(SMSCommon): @classmethod def setUpClass(cls): super().setUpClass() channel = cls.env['discuss.channel'].with_context(cls._test_context) cls.user_email = cls.user_employee cls.user_email.notification_type = 'email' cls.user_inbox = mail_new_test_user( cls.env, login='user_inbox', groups='base.group_user', name='User Inbox', notification_type='inbox' ) cls.record_simple = cls.env['mail.test.simple'].with_context(cls._test_context).create({ 'name': 'Test', 'email_from': 'ignasse@example.com' }) cls.record_simple.message_subscribe(partner_ids=[ cls.user_email.partner_id.id, cls.user_inbox.partner_id.id, ]) cls.direct_message_channel = channel.with_user(cls.user_email).create({ 'channel_partner_ids': [ (4, cls.user_email.partner_id.id), (4, cls.user_inbox.partner_id.id), ], 'channel_type': 'chat', 'name': 'Direct Message', }) cls.group_chat_channel = channel.with_user(cls.user_email).create_group(partners_to=(cls.user_email + cls.user_inbox).partner_id.ids) cls.group_channel = cls.env['discuss.channel'].channel_create(name='Channel', group_id=None) cls.group_channel.add_members((cls.user_email + cls.user_inbox).partner_id.ids) cls.env['mail.partner.device'].get_web_push_vapid_public_key() cls.vapid_public_key = cls.env['mail.partner.device'].get_web_push_vapid_public_key() cls.env['mail.partner.device'].sudo().create([{ 'endpoint': 'https://test.odoo.com/webpush/user1', 'expiration_time': None, 'keys': json.dumps({ 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', 'auth': 'DJFdtAgZwrT6yYkUMgUqow' }), 'partner_id': cls.user_email.partner_id.id, }]) cls.env['mail.partner.device'].sudo().create([{ 'endpoint': 'https://test.odoo.com/webpush/user2', 'expiration_time': None, 'keys': json.dumps({ 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', 'auth': 'DJFdtAgZwrT6yYkUMgUqow' }), 'partner_id': cls.user_inbox.partner_id.id, }]) def _trigger_cron_job(self): self.env.ref('mail.ir_cron_web_push_notification').method_direct_trigger() def _assert_notification_count_for_cron(self, number_of_notification): notification_count = self.env['mail.notification.web.push'].search_count([]) self.assertEqual(notification_count, number_of_notification) @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') def test_push_notifications(self, push_to_end_point): # Test No Inbox Condition self.record_simple.with_user(self.user_inbox).message_notify( partner_ids=self.user_email.partner_id.ids, body='Test', subject='Test Activity', record_name=self.record_simple._name, ) self._assert_notification_count_for_cron(0) push_to_end_point.assert_not_called() self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body='Test message send via Web Push', subject='Test Activity', record_name=self.record_simple._name, ) self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) self.assertIn(self.record_simple._name, payload_value['title']) self.assertIn(self.user_email.name, payload_value['title']) self.assertEqual(payload_value['options']['body'], 'Test message send via Web Push') self.assertEqual(payload_value['options']['data']['res_id'], self.record_simple.id) self.assertEqual(payload_value['options']['data']['model'], self.record_simple._name) self.assertIn('icon', payload_value['options']) self.assertIn('res.partner', payload_value['options']['icon']) self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2') self.assertIn('vapid_private_key', push_to_end_point.call_args.kwargs) self.assertIn('vapid_public_key', push_to_end_point.call_args.kwargs) # Reset the mock counter push_to_end_point.reset_mock() # Test Tracking Message mail_test_ticket = self.env['mail.test.ticket'].with_context(self._test_context) record_full = mail_test_ticket.with_user(self.user_email).create({ 'name': 'Test', }) record_full = record_full.with_context(mail_notrack=False) container = self.env['mail.test.container'].create({'name': 'Container'}) record_full.message_subscribe( partner_ids=[self.user_email.partner_id.id], subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id], ) record_full.write({ 'name': 'Test2', 'email_from': 'noone@example.com', 'container_id': container.id, }) self.flush_tracking() self._assert_notification_count_for_cron(0) push_to_end_point.assert_not_called() container2 = self.env['mail.test.container'].create({'name': 'Container Two'}) record_full.message_subscribe( partner_ids=[self.user_inbox.partner_id.id], subtype_ids=[self.env.ref('test_mail.st_mail_test_ticket_container_upd').id], ) record_full.write({ 'name': 'Test3', 'email_from': 'noone@example.com', 'container_id': container2.id, }) self.flush_tracking() self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) # As the tracking values are converted to text. We check the '→' added by ocn_client. self.assertIn('→', payload_value['options']['body'], 'No Tracking Message found') @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') def test_push_notifications_all_type(self, push_to_end_point): # Test Direct Message self.direct_message_channel.with_user(self.user_email).message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() self.assertEqual(push_to_end_point.call_args.kwargs['device']['endpoint'], 'https://test.odoo.com/webpush/user2') # Reset the mock counter push_to_end_point.reset_mock() # Test Group Chat (partner as author) self.group_chat_channel.add_members(guest_ids=[self.guest.id]) self.group_chat_channel.with_user(self.user_email).message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) self.assertIn(self.user_email.name, payload_value['title']) self.assertIn(self.user_inbox.name, payload_value['title']) self.assertIn(self.guest.name, payload_value['title']) self.assertNotIn("False", payload_value['title']) # Reset the mock counter push_to_end_point.reset_mock() # Test Group Chat (guest as author) self.group_chat_channel.with_user(self.env.ref('base.public_user')).with_context(guest=self.guest).message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') self._assert_notification_count_for_cron(0) self.assertEqual(push_to_end_point.call_count, 2) # the 2 partners payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) self.assertIn(self.guest.name, payload_value['title']) self.assertNotIn("False", payload_value['title']) # Reset the mock counter push_to_end_point.reset_mock() # Test Direct Message with channel Muted self.env['discuss.channel.member'].search([ ('partner_id', '=', self.user_inbox.partner_id.id), ('channel_id', '=', self.direct_message_channel.id), ]).write({ 'mute_until_dt': datetime(9999, 1, 1, 14, 00), }) self.direct_message_channel.with_user(self.user_email).message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment') self._assert_notification_count_for_cron(0) push_to_end_point.assert_not_called() # Reset the mock counter push_to_end_point.reset_mock() # Test Following Message self.record_simple.with_user(self.user_email).message_post( body='Test', message_type='comment', subtype_xmlid='mail.mt_comment' ) self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() # Reset the mock counter push_to_end_point.reset_mock() # Test Channel Message self.group_channel.with_user(self.user_email).message_post( body='Test', partner_ids=self.user_inbox.partner_id.ids, message_type='comment', subtype_xmlid='mail.mt_comment') self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() # Reset the mock counter push_to_end_point.reset_mock() # Test AtMention Message self.record_simple.with_user(self.user_email).message_post( body=Markup('@user') % self.user_inbox.partner_id.id, message_type='comment', subtype_xmlid='mail.mt_comment' ) self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() @patch.object(odoo.addons.mail.models.mail_thread, 'push_to_end_point') def test_push_notifications_mail_replay(self, push_to_end_point): test_record = self.env['mail.test.gateway'].with_context(self._test_context).create({ 'name': 'Test', 'email_from': 'ignasse@example.com', }) test_record.message_subscribe(partner_ids=[self.user_inbox.partner_id.id]) fake_email = self.env['mail.message'].create({ 'model': 'mail.test.gateway', 'res_id': test_record.id, 'subject': 'Public Discussion', 'message_type': 'email', 'subtype_id': self.env.ref('mail.mt_comment').id, 'author_id': self.user_email.partner_id.id, 'message_id': '<123456-openerp-%s-mail.test.gateway@%s>' % (test_record.id, socket.gethostname()), }) self.format_and_process( MAIL_TEMPLATE, self.user_email.email_formatted, self.user_inbox.email_formatted, subject='Test Subject Reply By mail', extra='In-Reply-To:\r\n\t%s\n' % fake_email.message_id, ) self._assert_notification_count_for_cron(0) push_to_end_point.assert_called_once() payload_value = json.loads(push_to_end_point.call_args.kwargs['payload']) self.assertIn(self.user_email.name, payload_value['title']) self.assertIn( 'Please call me as soon as possible this afternoon!\n\n--\nSylvie', payload_value['options']['body'], 'The body must contain the text send by mail' ) @patch.object(odoo.addons.mail.models.web_push, 'push_to_end_point') def test_push_notifications_cron(self, push_to_end_point): # Add 4 more devices to force sending via cron queue for index in range(10, 14): self.env['mail.partner.device'].sudo().create([{ 'endpoint': 'https://test.odoo.com/webpush/user%d' % index, 'expiration_time': None, 'keys': json.dumps({ 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', 'auth': 'DJFdtAgZwrT6yYkUMgUqow' }), 'partner_id': self.user_inbox.partner_id.id, }]) self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body='Test message send via Web Push', subject='Test Activity', record_name=self.record_simple._name, ) self._assert_notification_count_for_cron(5) # Force the execution of the cron self._trigger_cron_job() self.assertEqual(push_to_end_point.call_count, 5) @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(**{'status_code': 201, 'text': 'Ok'})) def test_push_notifications_encryption_simple(self, post): """ Test to see if all parameters sent to the endpoint are present. This test doesn't test if the cryptographic values are correct. """ self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body='Test message send via Web Push', subject='Test Activity', record_name=self.record_simple._name, ) self._assert_notification_count_for_cron(0) post.assert_called_once() self.assertEqual(post.call_args.args[0], 'https://test.odoo.com/webpush/user2') self.assertIn('headers', post.call_args.kwargs) self.assertIn('vapid', post.call_args.kwargs['headers']['Authorization']) self.assertIn('t=', post.call_args.kwargs['headers']['Authorization']) self.assertIn('k=', post.call_args.kwargs['headers']['Authorization']) self.assertEqual('aes128gcm', post.call_args.kwargs['headers']['Content-Encoding']) self.assertEqual('60', post.call_args.kwargs['headers']['TTL']) self.assertIn('data', post.call_args.kwargs) self.assertIn('timeout', post.call_args.kwargs) @patch.object(odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(**{'status_code': 404, 'text': 'Device Unreachable'})) def test_push_notifications_device_unreachable(self, post): with mute_logger('odoo.addons.mail.web_push'): self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body='Test message send via Web Push', subject='Test Activity', record_name=self.record_simple._name, ) self._assert_notification_count_for_cron(0) post.assert_called_once() # Test that the unreachable device is deleted from the DB notification_count = self.env['mail.partner.device'].search_count([('endpoint', '=', 'https://test.odoo.com/webpush/user2')]) self.assertEqual(notification_count, 0) def test_push_notification_regenerate_vpaid_keys(self): ir_params_sudo = self.env['ir.config_parameter'].sudo() ir_params_sudo.search([('key', 'in', [ 'mail.web_push_vapid_private_key', 'mail.web_push_vapid_public_key' ])]).unlink() new_vapid_public_key = self.env['mail.partner.device'].get_web_push_vapid_public_key() self.assertNotEqual(self.vapid_public_key, new_vapid_public_key) with self.assertRaises(InvalidVapidError): self.env['mail.partner.device'].register_devices( endpoint='https://test.odoo.com/webpush/user1', expiration_time=None, keys=json.dumps({ 'p256dh': 'BGbhnoP_91U7oR59BaaSx0JnDv2oEooYnJRV2AbY5TBeKGCRCf0HcIJ9bOKchUCDH4cHYWo9SYDz3U-8vSxPL_A', 'auth': 'DJFdtAgZwrT6yYkUMgUqow' }), partner_id=self.user_email.partner_id.id, vapid_public_key=self.vapid_public_key, ) @patch.object( odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok') ) @patch.object( odoo.addons.mail.models.mail_thread, 'push_to_end_point', wraps=odoo.addons.mail.web_push.push_to_end_point, ) def test_push_notifications_truncate_payload(self, thread_push_mock, session_post_mock): """Ensure that when we send large bodies with various character types, the final encrypted data (post-encryption) never exceeds 4096 bytes. This test checks the behavior for the current size limits and encryption overhead. See below test for a more illustrative example. See MailThread._truncate_payload for a more thorough explanation. Test scenarios include: - ASCII characters (X) - UTF-8 characters (Ø), at various offsets """ # compute the size of an empty notification with these parameters # this could change based on the id of record_simple for example # but is otherwise constant for any notification sent with the same parameters self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body='', subject='Test Payload', record_name=self.record_simple._name, ) base_payload_size = len(thread_push_mock.call_args.kwargs['payload'].encode()) effective_payload_size_limit = self.env['mail.thread']._truncate_payload_get_max_payload_length() # this is just a sanity check that the value makes sense, feel free to update as needed self.assertEqual(effective_payload_size_limit, 3993, "Payload limit should come out to 3990.") body_size_limit = effective_payload_size_limit - base_payload_size encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD test_cases = [ # (description, body) ('empty string', '', 0, 0), ('1-byte ASCII characters (below limit)', 'X' * (body_size_limit - 1), body_size_limit - 1, body_size_limit - 1), ('1-byte ASCII characters (at limit)', 'X' * body_size_limit, body_size_limit, body_size_limit), ('1-byte ASCII characters (past limit)', 'X' * (body_size_limit + 1), body_size_limit, body_size_limit), ('1-byte ASCII characters (way past limit)', 'X' * 5000, body_size_limit, body_size_limit), ] + [ # \u00d8 check that it can be cut anywhere by offsetting the string by 1 byte each time ( f'2-bytes UTF-8 characters (near limit + {offset}-byte offset)', ('+' * offset) + ('Ø' * (body_size_limit // 6)), offset + ((body_size_limit - offset) // 6), # length truncated to nearest full character (\u00f8) offset * 1 + ((body_size_limit - offset) // 6) * 6, ) for offset in range(0, 8) ] for description, body, expected_body_length, expected_body_size in test_cases: with self.subTest(description): self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body=body, subject='Test Payload', record_name=self.record_simple._name, ) encrypted_payload = session_post_mock.call_args.kwargs['data'] payload_before_encryption = thread_push_mock.call_args.kwargs['payload'] self.assertLessEqual( len(encrypted_payload), 4096, 'Final encrypted payload should not exceed 4096 bytes' ) self.assertEqual( len(json.loads(payload_before_encryption)['options']['body']), expected_body_length ) self.assertEqual( len(encrypted_payload), base_payload_size + expected_body_size + encryption_overhead, 'Encrypted size should be exactly the base payload size + body size + encryption overhead.' ) @patch.object( odoo.addons.mail.models.mail_thread.Session, 'post', return_value=SimpleNamespace(status_code=201, text='Ok') ) @patch.object( odoo.addons.mail.models.mail_thread, 'push_to_end_point', wraps=odoo.addons.mail.web_push.push_to_end_point, ) @patch.object( odoo.addons.mail.web_push, '_encrypt_payload', wraps=odoo.addons.mail.web_push._encrypt_payload, ) def test_push_notifications_truncate_payload_mocked_size_limit(self, web_push_encrypt_payload_mock, thread_push_mock, session_post_mock): """Illustrative test for text contents truncation. We want to ensure we truncate utf-8 values properly based on maximum payload size. Here max payload size is mocked, so that we can test on the same body each time to ease reading. See MailThread._truncate_payload for a more thorough explanation. """ self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body="", subject='Test Payload', record_name=self.record_simple._name, ) base_payload = thread_push_mock.call_args.kwargs['payload'].encode() base_payload_size = len(base_payload) encryption_overhead = ENCRYPTION_HEADER_SIZE + ENCRYPTION_BLOCK_OVERHEAD body = "BØDY" body_json = json.dumps(body)[1:-1] for size_limit, expected_body in [ (base_payload_size + len(body_json), "BØDY"), (base_payload_size + len(body_json) - 1, "BØD"), (base_payload_size + len(body_json) - 2, "BØ"), ] + [ # truncating anywhere in \u00d8 (Ø) should truncate to the nearest full character (B) (base_payload_size + len(body_json) - n, "B") for n in range(3, 9) ] + [ (base_payload_size + len(body_json) - 9, ""), (base_payload_size + len(body_json) - 10, ""), # should still work even if it would still be too big after truncate ]: with self.subTest(size_limit=size_limit), patch.object( odoo.addons.mail.models.mail_thread.MailThread, '_truncate_payload_get_max_payload_length', return_value=size_limit, ): self.record_simple.with_user(self.user_email).message_notify( partner_ids=self.user_inbox.partner_id.ids, body=body, subject='Test Payload', record_name=self.record_simple._name, ) payload_at_push = thread_push_mock.call_args.kwargs['payload'] payload_before_encrypt = web_push_encrypt_payload_mock.call_args.args[0] encrypted_payload = session_post_mock.call_args.kwargs['data'] self.assertEqual(payload_before_encrypt.decode(), payload_at_push, "Payload should not change between encryption and push call.") self.assertEqual(len(payload_before_encrypt), len(payload_at_push), "Encoded body should be same size as decoded.") self.assertEqual( len(encrypted_payload), len(payload_before_encrypt) + encryption_overhead, 'Final encrypted payload should just be the size of the unencrypted payload + the size of encryption overhead.' ) self.assertEqual( json.loads(payload_at_push)['options']['body'], expected_body ) if not expected_body: self.assertEqual( payload_before_encrypt, base_payload, "Only the contents of the body should be truncated, not the rest of the payload." )