Skip to content

Commit 1666efe

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 1666efe

3 files changed

Lines changed: 331 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: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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+
f"user1 (B2B only) hit AccessError on " f"_onchange_operating_unit: {e}"
166+
)
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
# © 2026 BITVAX
2+
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl.html).
3+
"""Bug: account.payment.register wizard's available_journal_ids
4+
includes journals from operating units the invoice does not belong to.
5+
6+
A B2B invoice should only offer journals whose operating_unit_id
7+
matches B2B (or no OU). On upstream/18.0 the wizard pulls journals via
8+
``account.journal.search()`` without OU context, so it returns every
9+
sale/cash journal of the company regardless of OU.
10+
11+
The fix is to override
12+
``account.payment.register._get_batch_available_journals`` and filter
13+
the returned set by the operating_unit_id of the invoices in the
14+
batch.
15+
"""
16+
from odoo.models import Command
17+
from odoo.tests import tagged
18+
19+
from odoo.addons.account.tests.common import AccountTestInvoicingCommon
20+
from odoo.addons.operating_unit.tests.common import OperatingUnitCommon
21+
22+
23+
@tagged("post_install", "-at_install")
24+
class TestPaymentRegisterJournalOu(
25+
AccountTestInvoicingCommon, OperatingUnitCommon
26+
):
27+
28+
@classmethod
29+
def setUpClass(cls):
30+
super().setUpClass()
31+
32+
(cls.ou1 | cls.b2b | cls.b2c).sudo().write(
33+
{"company_id": cls.company.id}
34+
)
35+
36+
cls.env.user.sudo().write(
37+
{
38+
"groups_id": [
39+
Command.link(
40+
cls.env.ref(
41+
"operating_unit.group_manager_operating_unit"
42+
).id
43+
),
44+
],
45+
"operating_unit_ids": [
46+
Command.link(cls.ou1.id),
47+
Command.link(cls.b2b.id),
48+
],
49+
"default_operating_unit_id": cls.ou1.id,
50+
"company_ids": [Command.link(cls.company.id)],
51+
"company_id": cls.company.id,
52+
}
53+
)
54+
55+
cls.user1.write(
56+
{
57+
"groups_id": [
58+
(
59+
3,
60+
cls.env.ref(
61+
"operating_unit.group_manager_operating_unit"
62+
).id,
63+
),
64+
Command.link(
65+
cls.env.ref(
66+
"operating_unit.group_multi_operating_unit"
67+
).id
68+
),
69+
Command.link(
70+
cls.env.ref("account.group_account_invoice").id
71+
),
72+
],
73+
"assigned_operating_unit_ids": [(6, 0, [cls.b2b.id])],
74+
"default_operating_unit_id": cls.b2b.id,
75+
"company_id": cls.company.id,
76+
"company_ids": [Command.link(cls.company.id)],
77+
}
78+
)
79+
80+
Journal = cls.env["account.journal"].sudo()
81+
cls.purchase_journal_b2b = Journal.create(
82+
{
83+
"name": "Vendor Bills B2B (test_pay_reg)",
84+
"code": "TPRPB",
85+
"type": "purchase",
86+
"company_id": cls.company.id,
87+
"operating_unit_id": cls.b2b.id,
88+
}
89+
)
90+
cls.cash_journal_ou1 = Journal.create(
91+
{
92+
"name": "Cash OU1 (test_pay_reg)",
93+
"code": "TPRC1",
94+
"type": "cash",
95+
"company_id": cls.company.id,
96+
"operating_unit_id": cls.ou1.id,
97+
}
98+
)
99+
cls.cash_journal_b2b = Journal.create(
100+
{
101+
"name": "Cash B2B (test_pay_reg)",
102+
"code": "TPRCB",
103+
"type": "cash",
104+
"company_id": cls.company.id,
105+
"operating_unit_id": cls.b2b.id,
106+
}
107+
)
108+
cls.expense_account = cls.env["account.account"].search(
109+
[
110+
("account_type", "=", "expense"),
111+
("company_ids", "in", cls.company.ids),
112+
],
113+
limit=1,
114+
)
115+
116+
def test_payment_register_filters_journals_by_invoice_ou(self):
117+
invoice = (
118+
self.env["account.move"]
119+
.with_context(default_move_type="in_invoice")
120+
.create(
121+
{
122+
"partner_id": self.partner1.id,
123+
"operating_unit_id": self.b2b.id,
124+
"invoice_date": "2026-01-01",
125+
"journal_id": self.purchase_journal_b2b.id,
126+
"invoice_line_ids": [
127+
(
128+
0,
129+
0,
130+
{
131+
"name": "Line",
132+
"quantity": 1,
133+
"price_unit": 100.0,
134+
"account_id": self.expense_account.id,
135+
"tax_ids": [],
136+
},
137+
)
138+
],
139+
}
140+
)
141+
)
142+
invoice.action_post()
143+
144+
wizard = (
145+
self.env["account.payment.register"]
146+
.with_user(self.user1)
147+
.with_context(
148+
active_model="account.move",
149+
active_ids=invoice.ids,
150+
)
151+
.create({"journal_id": self.cash_journal_b2b.id})
152+
)
153+
available = wizard.available_journal_ids
154+
self.assertIn(
155+
self.cash_journal_b2b,
156+
available,
157+
"B2B cash journal must be available to pay a B2B invoice.",
158+
)
159+
self.assertNotIn(
160+
self.cash_journal_ou1,
161+
available,
162+
"OU1 cash journal must NOT be available when paying a "
163+
"B2B invoice.",
164+
)

0 commit comments

Comments
 (0)