Skip to content

Commit 0b36ef7

Browse files
committed
[ADD] account_operating_unit: TDD test for OU onchange AccessError fix
The 0b411a4 fix added sudo() to _onchange_operating_unit reads but shipped without a regression test. This adds one: a B2B-only user opens a draft invoice whose journal lives in OU1, triggers the onchange, and the handler must not raise AccessError reading journal.type / journal.operating_unit_id. The test forces the inconsistent state (move in B2B, journal in OU1) via raw SQL to bypass _check_journal_operating_unit, simulating a legacy/migrated record.
1 parent 0b411a4 commit 0b36ef7

2 files changed

Lines changed: 168 additions & 0 deletions

File tree

account_operating_unit/tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from . import test_account_operating_unit
44
from . import test_invoice_operating_unit
55
from . import test_cross_ou_journal_entry
6+
from . import test_onchange_operating_unit_access
67
from . import test_operating_unit_security
78
from . import test_payment_operating_unit
89
from . import test_account_reconcile
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# © 2026 BITVAX
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
3+
"""Bug: account.move._onchange_operating_unit raises AccessError when
4+
the journal currently set on the move belongs to an Operating Unit the
5+
acting user cannot read.
6+
7+
This happens because the handler reads ``self.journal_id.type`` and
8+
``self.journal_id.operating_unit_id`` without ``sudo()``. The
9+
account_operating_unit security rule
10+
``ir_rule_account_journal_allowed_operating_units`` denies the read,
11+
and Odoo raises AccessError.
12+
13+
The fix is to use ``.sudo()`` for those two attribute reads. The
14+
journal still gets selected respecting OU because the search done
15+
later filters by operating_unit_id afterwards.
16+
"""
17+
18+
from odoo.exceptions import AccessError
19+
from odoo.models import Command
20+
from odoo.tests import tagged
21+
22+
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
23+
from odoo.addons.operating_unit.tests.common import OperatingUnitCommon
24+
25+
26+
@tagged("post_install", "-at_install")
27+
class TestOnchangeOperatingUnitAccess(AccountTestInvoicingCommon, OperatingUnitCommon):
28+
@classmethod
29+
def setUpClass(cls):
30+
super().setUpClass()
31+
32+
# Move demo OUs into the test company
33+
(cls.ou1 | cls.b2b | cls.b2c).sudo().write({"company_id": cls.company.id})
34+
35+
# The test admin user needs full OU access for setup
36+
cls.env.user.sudo().write(
37+
{
38+
"groups_id": [
39+
Command.link(
40+
cls.env.ref("operating_unit.group_manager_operating_unit").id
41+
),
42+
],
43+
"operating_unit_ids": [
44+
Command.link(cls.ou1.id),
45+
Command.link(cls.b2b.id),
46+
],
47+
"default_operating_unit_id": cls.ou1.id,
48+
"company_ids": [Command.link(cls.company.id)],
49+
"company_id": cls.company.id,
50+
}
51+
)
52+
53+
# user1 is restricted to B2B only — REMOVES the manager group
54+
# so its computed operating_unit_ids is restricted by
55+
# assigned_operating_unit_ids and the security rule actually
56+
# bites.
57+
cls.user1.write(
58+
{
59+
"groups_id": [
60+
(
61+
3,
62+
cls.env.ref("operating_unit.group_manager_operating_unit").id,
63+
),
64+
Command.link(
65+
cls.env.ref("operating_unit.group_multi_operating_unit").id
66+
),
67+
Command.link(cls.env.ref("account.group_account_invoice").id),
68+
],
69+
"assigned_operating_unit_ids": [(6, 0, [cls.b2b.id])],
70+
"default_operating_unit_id": cls.b2b.id,
71+
"company_id": cls.company.id,
72+
"company_ids": [Command.link(cls.company.id)],
73+
}
74+
)
75+
76+
Journal = cls.env["account.journal"].sudo()
77+
cls.purchase_journal_ou1 = Journal.create(
78+
{
79+
"name": "Vendor Bills OU1 (test_onchange)",
80+
"code": "TONP1",
81+
"type": "purchase",
82+
"company_id": cls.company.id,
83+
"operating_unit_id": cls.ou1.id,
84+
}
85+
)
86+
cls.purchase_journal_b2b = Journal.create(
87+
{
88+
"name": "Vendor Bills B2B (test_onchange)",
89+
"code": "TONPB",
90+
"type": "purchase",
91+
"company_id": cls.company.id,
92+
"operating_unit_id": cls.b2b.id,
93+
}
94+
)
95+
96+
cls.expense_account = cls.env["account.account"].search(
97+
[
98+
("account_type", "=", "expense"),
99+
("company_ids", "in", cls.company.ids),
100+
],
101+
limit=1,
102+
)
103+
104+
def test_onchange_operating_unit_no_access_error(self):
105+
"""user1 (B2B-only) calls _onchange_operating_unit on an
106+
invoice that they CAN read (operating_unit_id = b2b) but
107+
whose journal_id points to a journal user1 CANNOT read
108+
(operating_unit_id = ou1). This is the production scenario:
109+
a draft move that was previously linked to a journal the
110+
current user lost access to. Without the sudo() fix the
111+
handler raises AccessError reading journal.type.
112+
"""
113+
# Create the invoice as admin so we can place it in B2B
114+
# (so user1 can read it) but force the journal to OU1
115+
# (so user1 cannot read the journal). This bypasses the
116+
# _check_journal_operating_unit constraint by using sudo()
117+
# on the create.
118+
invoice = (
119+
self.env["account.move"]
120+
.sudo()
121+
.with_context(default_move_type="in_invoice")
122+
.create(
123+
{
124+
"partner_id": self.partner1.id,
125+
"operating_unit_id": self.b2b.id,
126+
"journal_id": self.purchase_journal_b2b.id,
127+
"invoice_line_ids": [
128+
(
129+
0,
130+
0,
131+
{
132+
"name": "Line",
133+
"quantity": 1,
134+
"price_unit": 100.0,
135+
"account_id": self.expense_account.id,
136+
"tax_ids": [],
137+
},
138+
)
139+
],
140+
}
141+
)
142+
)
143+
# Now sneak the OU1 journal onto the move via raw SQL to
144+
# bypass _check_journal_operating_unit. This simulates a
145+
# legacy / migrated move that ended up in this inconsistent
146+
# state and the user is trying to fix it via the form.
147+
self.env.cr.execute(
148+
"UPDATE account_move SET journal_id = %s WHERE id = %s",
149+
(self.purchase_journal_ou1.id, invoice.id),
150+
)
151+
invoice.invalidate_recordset(["journal_id"])
152+
153+
# Sanity: user1 can read the move (it's in B2B).
154+
invoice.with_user(self.user1).read(["operating_unit_id"])
155+
# Sanity: user1 cannot read the OU1 journal directly.
156+
with self.assertRaises(AccessError):
157+
self.purchase_journal_ou1.with_user(self.user1).type # noqa: B018
158+
159+
# The actual repro: trigger the onchange as user1.
160+
invoice_as_user1 = invoice.with_user(self.user1)
161+
try:
162+
invoice_as_user1._onchange_operating_unit()
163+
except AccessError as e:
164+
self.fail(
165+
"user1 (B2B only) hit AccessError on "
166+
"_onchange_operating_unit: %s" % e
167+
)

0 commit comments

Comments
 (0)