From d4c865199e4a01d3fd606d52e152f1c9fe3eb690 Mon Sep 17 00:00:00 2001 From: Pierre-Yves Dufays Date: Tue, 8 Nov 2022 16:08:17 +0000 Subject: [PATCH] [IMP] mail, test_mail_full: add portal flow test The tests consist in sending a mail related to a record to a customer and checking that the record can be viewed through the embedded link: - either in the backend if the user is connected and has the right to - or in the portal otherwise It also test that if the embedded link is tampered, the access is forbidden. Task-2797311 closes odoo/odoo#105405 Signed-off-by: Thibault Delavallee (tde) --- addons/mail/models/mail_thread.py | 2 +- addons/test_mail_full/__init__.py | 1 + addons/test_mail_full/controllers/__init__.py | 4 + addons/test_mail_full/controllers/portal.py | 14 ++ .../models/test_mail_models_mail.py | 9 +- .../security/ir.model.access.csv | 2 +- addons/test_mail_full/tests/test_portal.py | 189 +++++++++++++++++- 7 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 addons/test_mail_full/controllers/__init__.py create mode 100644 addons/test_mail_full/controllers/portal.py diff --git a/addons/mail/models/mail_thread.py b/addons/mail/models/mail_thread.py index 6ff66d3191d..7b6e412e194 100644 --- a/addons/mail/models/mail_thread.py +++ b/addons/mail/models/mail_thread.py @@ -2996,7 +2996,7 @@ class MailThread(models.AbstractModel): token = self._notify_encode_link(base_link, params) params['token'] = token - link = '%s?%s' % (base_link, urls.url_encode(params)) + link = '%s?%s' % (base_link, urls.url_encode(params, sort=True)) if self: link = self[0].get_base_url() + link diff --git a/addons/test_mail_full/__init__.py b/addons/test_mail_full/__init__.py index 567548c78f5..dfccc6b661b 100644 --- a/addons/test_mail_full/__init__.py +++ b/addons/test_mail_full/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from . import controllers from . import models from . import tests diff --git a/addons/test_mail_full/controllers/__init__.py b/addons/test_mail_full/controllers/__init__.py new file mode 100644 index 00000000000..903b755e71e --- /dev/null +++ b/addons/test_mail_full/controllers/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import portal diff --git a/addons/test_mail_full/controllers/portal.py b/addons/test_mail_full/controllers/portal.py new file mode 100644 index 00000000000..336e9d62e96 --- /dev/null +++ b/addons/test_mail_full/controllers/portal.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + + +from odoo import http +from odoo.http import request + + +class PortalTest(http.Controller): + """Implements some test portal routes (ex.: for viewing a record).""" + + @http.route('/my/test_portal/', type='http', auth='public', methods=['GET']) + def test_portal_record_view(self, res_id, access_token=None, **kwargs): + return request.make_response(f'Record view of test_portal {res_id} ({access_token}, {kwargs})') diff --git a/addons/test_mail_full/models/test_mail_models_mail.py b/addons/test_mail_full/models/test_mail_models_mail.py index 926b6808af2..a00bdb046a3 100644 --- a/addons/test_mail_full/models/test_mail_models_mail.py +++ b/addons/test_mail_full/models/test_mail_models_mail.py @@ -5,20 +5,21 @@ from odoo import api, fields, models class MailTestPortal(models.Model): - """ A model intheriting from mail.thread with some fields used for portal - sharing, like a partner, ...""" + """ A model inheriting from mail.thread and portal.mixin with some fields + used for portal sharing, like a partner, ...""" _description = 'Chatter Model for Portal' _name = 'mail.test.portal' _inherit = [ - 'mail.thread', 'portal.mixin', + 'mail.thread', ] name = fields.Char() partner_id = fields.Many2one('res.partner', 'Customer') + user_id = fields.Many2one(comodel_name='res.users', string="Salesperson") def _compute_access_url(self): - self.access_url = False + super()._compute_access_url() for record in self.filtered('id'): record.access_url = '/my/test_portal/%s' % self.id diff --git a/addons/test_mail_full/security/ir.model.access.csv b/addons/test_mail_full/security/ir.model.access.csv index 4fce169a33d..b5fe967c07a 100644 --- a/addons/test_mail_full/security/ir.model.access.csv +++ b/addons/test_mail_full/security/ir.model.access.csv @@ -1,5 +1,5 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_mail_test_portal_all,mail.test.portal.all,model_mail_test_portal,,1,0,0,0 +access_mail_test_portal_all,mail.test.portal.all,model_mail_test_portal,,0,0,0,0 access_mail_test_portal_user,mail.test.portal.user,model_mail_test_portal,base.group_user,1,1,1,1 access_mail_test_rating_all,mail.test.rating.all,model_mail_test_rating,,0,0,0,0 access_mail_test_rating_portal,mail.test.rating.portal,model_mail_test_rating,base.group_portal,1,0,0,0 diff --git a/addons/test_mail_full/tests/test_portal.py b/addons/test_mail_full/tests/test_portal.py index 1a83d25d343..44a086a9901 100644 --- a/addons/test_mail_full/tests/test_portal.py +++ b/addons/test_mail_full/tests/test_portal.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from werkzeug.urls import url_parse, url_decode +from werkzeug.urls import url_parse, url_decode, url_encode, url_unparse import json from odoo import http +from odoo.addons.mail.tests.common import MailCommon from odoo.addons.test_mail_full.tests.common import TestMailFullCommon from odoo.addons.test_mail_sms.tests.common import TestSMSRecipients from odoo.tests import tagged, users from odoo.tests.common import HttpCase +from odoo.tools import html_escape @tagged('portal') @@ -145,6 +147,191 @@ class TestPortalControllers(TestPortal): self.assertEqual(message.author_id, self.partner_2) +@tagged('portal') +class TestPortalFlow(MailCommon, HttpCase): + """Share a link by email to a customer without an account for viewing a record through the portal. + + The tests consist in sending a mail related to a record to a customer and checking that the record can be viewed + through the embedded link: + - either in the backend if the user is connected and has the right to + - or in the portal otherwise + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.customer = cls.env['res.partner'].create({ + 'country_id': cls.env.ref('base.fr').id, + 'email': 'mdelvaux34@example.com', + 'lang': 'en_US', + 'mobile': '+33639982325', + 'name': 'Mathias Delvaux', + 'phone': '+33353011823', + }) + cls.record_portal = cls.env['mail.test.portal'].create({ + 'name': 'Test Portal Record', + 'partner_id': cls.customer.id, + 'user_id': cls.user_admin.id, + }) + cls.mail_template = cls.env['mail.template'].create({ + 'auto_delete': True, + 'body_html': '

Hello , your quotation is ready for review.

', + 'email_from': '{{ (object.user_id.email_formatted or user.email_formatted) }}', + 'model_id': cls.env.ref('test_mail_full.model_mail_test_portal').id, + 'name': 'Quotation template', + 'partner_to': '{{ object.partner_id.id }}', + 'subject': 'Your quotation "{{ object.name }}"', + }) + cls._create_portal_user() + for group_name, group_func, group_data in cls.record_portal.sudo()._notify_get_recipients_groups(): + if group_name == 'portal_customer' and group_func(cls.customer): + cls.record_access_url = group_data['button_access']['url'] + break + else: + raise AssertionError('Record access URL not found') + # Build record_access_url_wrong_token with altered access_token for testing security + parsed_url = url_parse(cls.record_access_url) + query_params = url_decode(parsed_url.query) + cls.record_access_url_wrong_token = url_unparse( + (parsed_url[0], parsed_url[1], parsed_url[2], + url_encode({**query_params, + 'access_token': query_params['access_token'].translate( + str.maketrans('0123456789abcdef', + '9876543210fedcba'))}, + sort=True), + parsed_url[4])) + + def assert_URL(self, url, expected_path, expected_fragment_params=None, expected_query=None): + """Asserts that the URL has the expected path and if set, the expected fragment parameters and query.""" + parsed_url = url_parse(url) + fragment_params = url_decode(parsed_url.fragment) + self.assertEqual(parsed_url.path, expected_path) + if expected_fragment_params: + for key, expected_value in expected_fragment_params.items(): + self.assertEqual(fragment_params.get(key), expected_value, + f'Expected: "{key}={expected_value}" (for path: {expected_path})') + if expected_query: + self.assertEqual(expected_query, parsed_url.query, + f'Expected: query="{expected_query}" (for path: {expected_path})') + + def _get_composer_with_context(self, template_id=False): + return self.env['mail.compose.message'].with_context({ + 'default_composition_mode': 'comment', + 'default_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature', + 'default_model': self.record_portal._name, + 'default_res_id': self.record_portal.id, + 'default_template_id': template_id, + 'default_use_template': template_id, + 'force_email': True, + 'lang': '{{ object.partner_id.lang }}', + }) + + def test_initial_data(self): + """Test some initial values. + + Test that record_access_url is a valid URL to view the record_portal and that record_access_url_wrong_token + only differs from record_access_url by a different access_token. + """ + parsed_record_access_url = url_parse(self.record_access_url) + record_access_query_params = url_decode(parsed_record_access_url.query) + parsed_record_access_url_wrong_token = url_parse(self.record_access_url_wrong_token) + record_access_wrong_token_query_params = url_decode(parsed_record_access_url_wrong_token.query) + + self.assertEqual(parsed_record_access_url.path, '/mail/view') + # Note that pid, hash and auth_signup_token are not tested by this test but may be present in the URL (config). + self.assertEqual(record_access_query_params.get('model'), 'mail.test.portal') + self.assertEqual(int(record_access_query_params.get('res_id')), self.record_portal.id) + self.assertTrue(record_access_query_params.get('access_token')) + + self.assertNotEqual(self.record_access_url, self.record_access_url_wrong_token) + self.assertEqual(parsed_record_access_url_wrong_token.path, '/mail/view') + self.assertTrue(record_access_wrong_token_query_params['access_token']) + self.assertNotEqual(record_access_query_params['access_token'], + record_access_wrong_token_query_params['access_token']) + self.assertEqual({k: v for k, v in record_access_query_params.items() if k != 'access_token'}, + {k: v for k, v in record_access_wrong_token_query_params.items() if k != 'access_token'}) + + @users('portal_test') + def test_customer_access_logged_without_access(self): + """Check that the link redirects the customer (without backend access) to the portal for viewing the record.""" + self.authenticate(self.env.user.login, self.env.user.login) + res = self.url_open(self.record_access_url) + self.assertEqual(res.status_code, 200) + self.assert_URL(res.url, f'/my/test_portal/{self.record_portal.id}') + + @users('portal_test') + def test_customer_access_logged_without_access_wrong_token(self): + """Check that it redirects to discuss when logged customer has no access to the record and token is invalid.""" + self.authenticate(self.env.user.login, self.env.user.login) + res = self.url_open(self.record_access_url_wrong_token) + self.assertEqual(res.status_code, 200) + self.assert_URL(res.url, '/my', {'action': 'mail.action_discuss'}) + + def test_customer_access_not_logged(self): + """Check that the access link redirects the customer (not logged) to the portal for viewing the record.""" + res = self.url_open(self.record_access_url) + self.assertEqual(res.status_code, 200) + self.assertIn(f'/my/test_portal/{self.record_portal.id}', res.url) + self.assert_URL(res.url, f'/my/test_portal/{self.record_portal.id}') + + def test_customer_access_not_logged_wrong_token(self): + """Check that the access link redirect the customer to login when the token is invalid.""" + res = self.url_open(self.record_access_url_wrong_token) + self.assertEqual(res.status_code, 200) + self.assert_URL(res.url, '/web/login', {'model': 'mail.test.portal', 'id': str(self.record_portal.id)}, + expected_query='redirect=') + + @users('employee') + def test_employee_access(self): + """Check that the access link redirects an employee to the backend for viewing the record.""" + self.authenticate(self.env.user.login, self.env.user.login) + res = self.url_open(self.record_access_url) + self.assertEqual(res.status_code, 200) + self.assert_URL(res.url, '/web', {'model': 'mail.test.portal', 'id': str(self.record_portal.id)}) + + @users('employee') + def test_employee_access_wrong_token(self): + """Check that the access link redirects an employee to the record even if the token invalid.""" + self.authenticate(self.env.user.login, self.env.user.login) + res = self.url_open(self.record_access_url_wrong_token) + self.assertEqual(res.status_code, 200) + self.assert_URL(res.url, '/web', {'model': 'mail.test.portal', 'id': str(self.record_portal.id)}) + + @users('employee') + def test_send_message_to_customer(self): + """Same as test_send_message_to_customer_using_template but without a template.""" + composer = self._get_composer_with_context().create({ + 'body': '

Hello Mathias Delvaux, your quotation is ready for review.

', + 'partner_ids': self.customer.ids, + 'subject': 'Your Quotation "a white table"', + }) + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + self.assertEqual(len(self._mails), 1) + self.assertIn(f'"{html_escape(self.record_access_url)}"', self._mails[0].get('body')) + # Check that the template is not used (not the same subject) + self.assertEqual('Your Quotation "a white table"', self._mails[0].get('subject')) + self.assertIn('Hello Mathias Delvaux', self._mails[0].get('body')) + + @users('employee') + def test_send_message_to_customer_using_template(self): + """Send a mail to a customer without an account and check that it contains a link to view the record. + + Other tests below check that that same link has the correct behavior. + This test follows the common use case by using a template while the next send the mail without a template.""" + composer = self._get_composer_with_context(self.mail_template.id).create({}) + # currently onchange necessary + composer._onchange_template_id_wrapper() + + with self.mock_mail_gateway(mail_unlink_sent=True): + composer._action_send_mail() + + self.assertEqual(len(self._mails), 1) + self.assertIn(f'"{html_escape(self.record_access_url)}"', self._mails[0].get('body')) + self.assertEqual(f'Your quotation "{self.record_portal.name}"', self._mails[0].get('subject')) # Check that the template is used + + @tagged('portal') class TestPortalMixin(TestPortal):