From 415420f58256b35b1eaf7e3dd9a1d9782c3c55b8 Mon Sep 17 00:00:00 2001 From: Danny Adair Date: Wed, 22 Apr 2026 15:54:09 +1200 Subject: [PATCH 1/2] [FIX] queue_job: repoint default_env to SUPERUSER in runjob runjob is declared auth="none", so _auth_method_none pins request.env and transaction.default_env to a uid=None env. `env = http.request.env( user=SUPERUSER_ID)` only creates a local superuser env, leaving the default_env as uid=None. Any flush that goes through Transaction.flush() -> default_env.flush_all() then recomputes stored fields as uid=None and fails on anything dereferencing self.env.user. `http.request.update_env(user=SUPERUSER_ID)` additionally sets transaction.default_env to the superuser env. Closes #922 --- queue_job/controllers/main.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/queue_job/controllers/main.py b/queue_job/controllers/main.py index b4a5a596c..46da185d4 100644 --- a/queue_job/controllers/main.py +++ b/queue_job/controllers/main.py @@ -214,7 +214,8 @@ def _get_failure_values(cls, job, traceback_txt, orig_exception): readonly=False, ) def runjob(self, job_uuid, **kw): - env = http.request.env(user=SUPERUSER_ID) + http.request.update_env(user=SUPERUSER_ID) + env = http.request.env job = self._acquire_job(env, job_uuid) if not job: return "" From fd214cb0b07014b23d471b4da7b70e2a14e25b96 Mon Sep 17 00:00:00 2001 From: "Danny W. Adair" Date: Wed, 1 Jul 2026 11:13:47 +0000 Subject: [PATCH 2/2] [ADD] queue_job: HttpCase regression tests for /queue_job/runjob Adds test_run_job_controller_http.py covering #922: - test_runjob_pins_default_env_to_superuser asserts that when _acquire_job is entered, env.transaction.default_env.uid is SUPERUSER_ID -- the state a job would inherit at perform time. - test_runjob_without_updating_default_env_leaves_uid_none patches Request.update_env to a no-op and verifies default_env.uid stays None, isolating default_env repointing as the load-bearing behavior of runjob. Tests use HttpCase so _auth_method_none, session handling, and update_env run for real. _acquire_job is patched to return None so the endpoint exits cleanly without performing DB work (its internal commit is forbidden inside tests). --- queue_job/tests/__init__.py | 1 + .../tests/test_run_job_controller_http.py | 85 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 queue_job/tests/test_run_job_controller_http.py diff --git a/queue_job/tests/__init__.py b/queue_job/tests/__init__.py index 16bcdff96..696549a65 100644 --- a/queue_job/tests/__init__.py +++ b/queue_job/tests/__init__.py @@ -1,3 +1,4 @@ +from . import test_run_job_controller_http from . import test_run_rob_controller from . import test_runner_channels from . import test_runner_runner diff --git a/queue_job/tests/test_run_job_controller_http.py b/queue_job/tests/test_run_job_controller_http.py new file mode 100644 index 000000000..5ad88c1b8 --- /dev/null +++ b/queue_job/tests/test_run_job_controller_http.py @@ -0,0 +1,85 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from unittest import mock + +from odoo import SUPERUSER_ID, http +from odoo.tests.common import HttpCase, tagged + +from ..controllers.main import RunJobController + + +@tagged("-at_install", "post_install") +class TestRunJobHttp(HttpCase): + """End-to-end tests for the ``/queue_job/runjob`` endpoint. + + ``runjob`` is declared ``auth="none"``, so ``_auth_method_none`` pins + ``transaction.default_env`` to an env with ``uid=None``. The controller + must additionally repoint ``default_env`` to a superuser env, otherwise + any flush during ``job.perform()`` that goes through + ``Transaction.flush() -> default_env.flush_all()`` recomputes stored + fields with ``self.env.user`` as an empty recordset. See #922. + """ + + def _capture_default_env_uid_at_acquire(self): + """Hit ``/queue_job/runjob`` and observe ``transaction.default_env.uid`` + at the moment ``_acquire_job`` is entered. + + ``_acquire_job`` is patched to return ``None`` so the endpoint exits + without performing the job. This avoids the commit inside + ``_acquire_job``, which is forbidden inside tests, and is sufficient + to observe the env state established by the request-handling + machinery: nothing between ``update_env`` and ``_acquire_job`` + touches ``transaction.default_env``, so the value seen here is the + same value a job would see at ``perform()`` time. + + HttpCase runs the HTTP server in the same process, so class-level + ``mock.patch`` calls take effect on the server side as well. + """ + captured = {} + + def spy(cls, env, job_uuid): + captured["default_env_uid"] = env.transaction.default_env.uid + return None + + with mock.patch.object(RunJobController, "_acquire_job", classmethod(spy)): + response = self.url_open( + f"/queue_job/runjob?db={self.env.cr.dbname}&job_uuid=stub" + ) + response.raise_for_status() + + return captured.get("default_env_uid") + + def test_runjob_pins_default_env_to_superuser(self): + """``runjob`` must set ``transaction.default_env`` to a superuser env. + + Verifies that by the time ``_acquire_job`` is entered, + ``env.transaction.default_env.uid`` is ``SUPERUSER_ID``. This is the + state that lets flushes triggered by the job recompute stored fields + with a real ``self.env.user`` rather than an empty recordset. + """ + default_env_uid = self._capture_default_env_uid_at_acquire() + self.assertEqual(default_env_uid, SUPERUSER_ID) + + def test_runjob_without_updating_default_env_leaves_uid_none(self): + """Repointing ``default_env`` is the specific mechanism this relies on. + + Neutralises ``http.request.update_env`` for the duration of the + request, matching what happens if a ``runjob`` implementation only + obtains a local superuser env via ``http.request.env(user=...)`` + without invoking ``update_env``. The observable ``default_env.uid`` + then remains at the ``None`` value installed by + ``_auth_method_none``. This isolates ``default_env`` repointing as + the load-bearing behavior of ``runjob``: any future refactor that + drops it will trip this test. + """ + + def no_op_update_env(self_, user=None, context=None, su=None): + """Alternative ``Request.update_env`` that intentionally does + nothing. Simulates a controller that never asks the request to + update its environment. + """ + + with mock.patch.object(http.Request, "update_env", no_op_update_env): + default_env_uid = self._capture_default_env_uid_at_acquire() + + self.assertIsNone(default_env_uid)