From c2485724c1fbd5ee1b0b8f23eefa6bcfde4885d5 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Tue, 6 Oct 2026 16:17:01 +0200 Subject: [PATCH 1/3] [FIX] base_tier_validation: no access to reviews for portal and public users The 17.0 migration replaced the group-less access rules on tier.review and tier.definition by one rule per group, and so gave portal and public users read access to them. Tier validation has no record rule besides the multi-company one, so a portal user could read every review (reviewers, comments) and every definition of the company through RPC, not only those of their own documents. Portal pages and portal actions (view, PDF, chatter, sign) work on the document with sudo and do not need these rules. --- .../security/ir.model.access.csv | 4 ---- base_tier_validation/tests/__init__.py | 1 + .../tests/test_tier_validation_access.py | 18 ++++++++++++++++++ 3 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 base_tier_validation/tests/test_tier_validation_access.py diff --git a/base_tier_validation/security/ir.model.access.csv b/base_tier_validation/security/ir.model.access.csv index 82c76c65b..ed5b411ed 100644 --- a/base_tier_validation/security/ir.model.access.csv +++ b/base_tier_validation/security/ir.model.access.csv @@ -1,8 +1,4 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_tier_review_group_public,access_tier_review_group_public,model_tier_review,base.group_public,1,0,0,0 -access_tier_definition_group_public,access_tier_definition_group_public,model_tier_definition,base.group_public,1,0,0,0 -access_tier_review_group_portal,access_tier_review_group_portal,model_tier_review,base.group_portal,1,0,0,0 -access_tier_definition_group_portal,access_tier_definition_group_portal,model_tier_definition,base.group_portal,1,0,0,0 access_tier_review,access.tier.review,model_tier_review,base.group_user,1,1,1,1 access_tier_definition_all,tier.definition.all,model_tier_definition,base.group_user,1,0,0,0 access_tier_definition_settings,tier.definition.settings,model_tier_definition,base.group_erp_manager,1,1,1,1 diff --git a/base_tier_validation/tests/__init__.py b/base_tier_validation/tests/__init__.py index 9859e84ca..e4a78140e 100644 --- a/base_tier_validation/tests/__init__.py +++ b/base_tier_validation/tests/__init__.py @@ -2,3 +2,4 @@ from . import test_tier_validation from . import test_tier_validation_reminder +from . import test_tier_validation_access diff --git a/base_tier_validation/tests/test_tier_validation_access.py b/base_tier_validation/tests/test_tier_validation_access.py new file mode 100644 index 000000000..f1e59062c --- /dev/null +++ b/base_tier_validation/tests/test_tier_validation_access.py @@ -0,0 +1,18 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +from odoo.exceptions import AccessError +from odoo.tests import new_test_user, tagged + +from .common import CommonTierValidation + + +@tagged("post_install", "-at_install") +class TierValidationAccess(CommonTierValidation): + def test_portal_and_public_cannot_read_reviews(self): + self.test_record.with_user(self.test_user_2).request_validation() + self.assertTrue(self.test_record.review_ids) + portal = new_test_user(self.env, login="tv_portal", groups="base.group_portal") + public = self.env.ref("base.public_user") + for user in (portal, public): + for model in ("tier.review", "tier.definition"): + with self.assertRaises(AccessError): + self.env[model].with_user(user).search([]) From 984638c6741f6e88e3a9621025633c983c1bc31b Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Tue, 6 Oct 2026 16:17:01 +0200 Subject: [PATCH 2/3] [IMP] sale_tier_validation: test the portal with reviews A portal customer, or a public user with the access token, can view, discuss and sign a quotation that has reviews, while they cannot read the reviews themselves. --- sale_tier_validation/tests/__init__.py | 1 + sale_tier_validation/tests/test_portal.py | 93 +++++++++++++++++++++++ 2 files changed, 94 insertions(+) create mode 100644 sale_tier_validation/tests/test_portal.py diff --git a/sale_tier_validation/tests/__init__.py b/sale_tier_validation/tests/__init__.py index e1a50db59..ea33e6152 100644 --- a/sale_tier_validation/tests/__init__.py +++ b/sale_tier_validation/tests/__init__.py @@ -1,2 +1,3 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from . import test_tier_validation +from . import test_portal diff --git a/sale_tier_validation/tests/test_portal.py b/sale_tier_validation/tests/test_portal.py new file mode 100644 index 000000000..24d22baa5 --- /dev/null +++ b/sale_tier_validation/tests/test_portal.py @@ -0,0 +1,93 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import Command +from odoo.tests import tagged +from odoo.tests.common import new_test_user + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal + +SIGNATURE = "R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7" + + +@tagged("post_install", "-at_install") +class TestSaleTierValidationPortal(HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.reviewer = new_test_user( + cls.env, + login="portal_test_reviewer", + groups="sales_team.group_sale_salesman_all_leads", + ) + so_model = cls.env.ref("sale.model_sale_order") + cls.env["tier.definition"].create( + { + "model_id": so_model.id, + "review_type": "individual", + "reviewer_id": cls.reviewer.id, + } + ) + # Without it, signing is refused while the quotation is not confirmed + cls.env["tier.validation.exception"].create( + { + "model_id": so_model.id, + "field_ids": [ + Command.set( + so_model.field_id.filtered( + lambda f: f.name in ("signature", "signed_by", "signed_on") + ).ids + ) + ], + } + ) + product = cls.env["product.product"].create( + {"name": "Product for test", "list_price": 100.0} + ) + cls.order = cls.env["sale.order"].create( + { + "partner_id": cls.partner_portal.id, + "require_signature": True, + "require_payment": False, + "order_line": [Command.create({"product_id": product.id})], + } + ) + cls.order.action_quotation_sent() + cls.order.request_validation() + + def test_portal_user_cannot_read_reviews(self): + self.assertTrue(self.order.review_ids) + portal_env = self.env(user=self.user_portal) + for model in ("tier.review", "tier.definition"): + self.assertFalse(portal_env[model].has_access("read")) + + def test_portal_flow_with_reviews(self): + self.authenticate(self.user_portal.login, self.user_portal.login) + response = self.url_open(f"/my/orders/{self.order.id}") + self.assertEqual(response.status_code, 200) + self.assertIn(self.order.name, response.text) + self.make_jsonrpc_request( + "/mail/message/post", + { + "thread_model": "sale.order", + "thread_id": self.order.id, + "post_data": {"body": "Any news on the approval?"}, + }, + ) + self.order.with_user(self.reviewer).validate_tier() + self.assertEqual(self.order.validation_status, "validated") + self.make_jsonrpc_request( + f"/my/orders/{self.order.id}/accept", + {"name": "Joel Willis", "signature": SIGNATURE}, + ) + self.assertEqual(self.order.state, "sale") + + def test_public_flow_with_reviews(self): + token = self.order._portal_ensure_token() + response = self.url_open(f"/my/orders/{self.order.id}?access_token={token}") + self.assertEqual(response.status_code, 200) + self.assertIn(self.order.name, response.text) + self.order.with_user(self.reviewer).validate_tier() + self.make_jsonrpc_request( + f"/my/orders/{self.order.id}/accept?access_token={token}", + {"name": "Joel Willis", "signature": SIGNATURE}, + ) + self.assertEqual(self.order.state, "sale") From b27effc9d60bf35afe58294970b024557dec34b7 Mon Sep 17 00:00:00 2001 From: bosd <5e2fd43-d292-4c90-9d1f-74ff3436329a@anonaddy.me> Date: Tue, 6 Oct 2026 16:17:01 +0200 Subject: [PATCH 3/3] [IMP] purchase_tier_validation: test the portal with reviews A portal vendor, or a public user with the access token, can view a purchase order that has reviews, while they cannot read the reviews themselves. --- purchase_tier_validation/tests/__init__.py | 1 + purchase_tier_validation/tests/test_portal.py | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 purchase_tier_validation/tests/test_portal.py diff --git a/purchase_tier_validation/tests/__init__.py b/purchase_tier_validation/tests/__init__.py index f39596410..bb3410c31 100644 --- a/purchase_tier_validation/tests/__init__.py +++ b/purchase_tier_validation/tests/__init__.py @@ -1,3 +1,4 @@ # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). from . import test_tier_validation +from . import test_portal diff --git a/purchase_tier_validation/tests/test_portal.py b/purchase_tier_validation/tests/test_portal.py new file mode 100644 index 000000000..0b11101c9 --- /dev/null +++ b/purchase_tier_validation/tests/test_portal.py @@ -0,0 +1,55 @@ +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import Command +from odoo.tests import tagged +from odoo.tests.common import new_test_user + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal + + +@tagged("post_install", "-at_install") +class TestPurchaseTierValidationPortal(HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super().setUpClass() + reviewer = new_test_user( + cls.env, login="portal_test_reviewer", groups="purchase.group_purchase_user" + ) + cls.env["tier.definition"].create( + { + "model_id": cls.env.ref("purchase.model_purchase_order").id, + "review_type": "individual", + "reviewer_id": reviewer.id, + } + ) + product = cls.env["product.product"].create({"name": "Product for test"}) + cls.order = cls.env["purchase.order"].create( + { + "partner_id": cls.partner_portal.id, + "order_line": [ + Command.create({"product_id": product.id, "price_unit": 10.0}) + ], + } + ) + cls.order.write({"state": "sent"}) + cls.order.request_validation() + + def test_portal_user_cannot_read_reviews(self): + self.assertTrue(self.order.review_ids) + portal_env = self.env(user=self.user_portal) + for model in ("tier.review", "tier.definition"): + self.assertFalse(portal_env[model].has_access("read")) + + def test_portal_vendor_views_order_under_validation(self): + self.authenticate(self.user_portal.login, self.user_portal.login) + for url in ( + f"/my/purchase/{self.order.id}", + f"/my/purchase/{self.order.id}?report_type=pdf", + ): + response = self.url_open(url) + self.assertEqual(response.status_code, 200) + + def test_public_views_order_under_validation(self): + token = self.order._portal_ensure_token() + response = self.url_open(f"/my/purchase/{self.order.id}?access_token={token}") + self.assertEqual(response.status_code, 200) + self.assertIn(self.order.name, response.text)