fix(budgets): reject personal workspaces in get_user_budget_row - #413
aivong-openhands wants to merge 3 commits into
Conversation
Every other budget entry point -- get_budget_state, update_budget_settings, upsert_user_override and delete_user_override -- starts with _reject_personal_org. get_user_budget_row did not: it went straight to _get_or_create_settings, so reading a user row for a personal workspace wrote the OrgBudgetSettings row the other four exist to prevent, and that migration 148 exists to delete. Its only route reaches it after upsert_user_override has already rejected personal orgs, so this was a missing guard rather than a live leak. Nothing stopped the next caller from reaching it unguarded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage reportClick to see where and how coverage changed
This report was generated by python-coverage-comment-action |
||||||||||||||||||||||||
aivong-openhands
left a comment
There was a problem hiding this comment.
🟢 Taste Rating: Good taste
One line, added where four sibling entry points already have it. The guard goes before _get_or_create_settings, which is the only ordering that actually prevents the write. The un-skipped test asserts both the 400 and the absence of the settings row, so it fails if the guard moves or disappears. Nothing to argue with.
[IMPROVEMENT OPPORTUNITIES] (non-blocking)
-
[
server/services/org_budget_service.py,_get_or_create_settings] Special Case: This PR is the fifth copy of the same guard. The underlying shape is that a read path (get_user_budget_row,get_budget_state) calls a helper that writes. Every new entry point has to remember the guard, and forgetting it fails silently — exactly the defect this PR closes. The design that eliminates the special case is splitting the helper: a read-only accessor for read paths, and_get_or_create_settingsonly where a row genuinely must exist. Then the guard is load-bearing in one place instead of five. Out of scope for a one-line bug fix, but worth a follow-up issue rather than a sixth copy later. -
[
server/routes/orgs.py:1311-1317]upsert_org_budget_overridenow runs_is_personal_orgtwice per request (once insideupsert_user_override, once insideget_user_budget_row). One extra indexed single-row lookup on a low-traffic admin endpoint — not worth changing, just noting it is intentional and not free.
[TESTING GAPS]
None. test_user_budget_row_rejects_personal_org_without_creating_settings exercises the real service against a real Postgres session and asserts on state (no settings row) rather than on mock calls. It is a genuine regression test.
[RISK ASSESSMENT]
- [Overall PR]
⚠️ Risk Assessment: 🟢 LOW
Adds a rejection to a path that, per the PR's own analysis, is only reachable today behind an identical guard — so no live caller changes behaviour. The blast radius if that analysis is wrong is a 400 on an admin-only budgets read, not data loss. CI is green across all checks.
One note on evidence: the How to Test block is a pytest invocation, and test output alone is normally not accepted as proof of a working change. It is acceptable here because the change is a guard whose entire observable behaviour is the assertion (400 raised, no row written) — there is no separate runtime artifact to produce. No UI change, so no screenshot is expected.
VERDICT:
✅ Worth merging: The fix is correct, minimal, and properly pinned.
KEY INSIGHT:
The bug is not the missing guard, it is that a read helper writes; this PR correctly patches the symptom, and the root cause deserves its own follow-up before a sixth caller forgets.
Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:
- Add a
.agents/skills/custom-codereview-guide.mdfile to your branch (or edit it if one already exists) with the/codereviewtrigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.- Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
- When your PR is merged, the guideline file goes through normal code review by repository maintainers.
Resolve with AI? Install the iterate skill in your agent and run
/iterateto automatically drive this PR through CI, review, and QA until it's merge-ready.Was this review helpful? React with 👍 or 👎 to give feedback.
This review was generated by an AI agent (OpenHands) on behalf of @aivong-openhands.
|
Filed the architectural follow-up from the review as #430 — splitting Leaving this PR as the minimal one-line fix. This comment was created by an AI agent (OpenHands) on behalf of @aivong-openhands. |
Mutation review of the tests in this PRI hand-wrote a small mutant set against the one test this PR un-skips Controls — the fix is genuinely asserted
Both controls die, and C2 is the one that matters: your test doesn't stop at Survivors
M1 — I think this is an equivalent mutant, not a real gapDropping the If you do want the oracle observation pinned (it's the signal the Quint model @pytest.mark.asyncio
async def test_user_budget_row_rejection_is_recorded_by_quint_oracle(
async_session_maker, personal_org
):
async with async_session_maker() as session:
service = OrgBudgetService(session)
oracle = MagicMock()
oracle.In = lambda value, domain: value
with patch('server.services.org_budget_service.quint_oracle', oracle):
with pytest.raises(HTTPException):
await service.get_user_budget_row(personal_org.id, personal_org.id)
oracle.log.assert_called_once()
assert oracle.log.call_args.args[0] == 'get_user_budget_row'I verified both halves: it passes on this branch unmodified, and it fails once Not a test gapThe single test carries this one-line change well — nothing else in the diff is This comment was generated by an AI assistant on behalf of the user. |
Kills mutation M1 from the PR review: dropping the entry-point label passed to _reject_personal_org would previously go unnoticed. The label is the signal the Quint model keys on, so assert the oracle records it. Co-authored-by: openhands <openhands@all-hands.dev>
|
Thanks for the mutation review — agreed on the controls and on the analysis of M1. On the environment where the app actually runs, M1 is an equivalent mutant as you say ( @pytest.mark.asyncio
async def test_user_budget_row_rejection_is_recorded_by_quint_oracle(
async_session_maker, personal_org
):
oracle = MagicMock()
oracle.In = lambda value, domain: value
async with async_session_maker() as session:
service = OrgBudgetService(session)
with patch('server.services.org_budget_service.quint_oracle', oracle):
with pytest.raises(HTTPException):
await service.get_user_budget_row(personal_org.id, personal_org.id)
oracle.log.assert_called_once()
assert oracle.log.call_args.args[0] == 'get_user_budget_row'Verified both halves: passes on the branch, and fails with This comment was created by an AI agent (OpenHands) on behalf of the user. |
HUMAN:
AGENT:
Why
get_user_budget_rowwas the one budget entry point with no personal-workspace check.get_budget_state,update_budget_settings,upsert_user_overrideanddelete_user_overrideall begin with_reject_personal_org; this one went straight to_get_or_create_settings, so reading a user row for a personal workspace created theOrgBudgetSettingsrow those four exist to prevent — the same row migration 148 exists to delete, and the row an existing test asserts must never appear.Today the only route to it runs after
upsert_user_overridehas already rejected personal orgs, so this is a missing guard rather than a live leak. It is worth closing because nothing stops the next caller from reaching it unguarded, and the failure is silent: a stray settings row for a personal workspace, not an error.Summary
_reject_personal_orgat the top ofget_user_budget_row, matching the other four entry points.Issue Number
N/A
How to Test
Expect
168 passed, 6 skipped. Removing the guard makestest_user_budget_row_rejects_personal_org_without_creating_settingsfail, leaving a settings row behind for the personal workspace.Video/Screenshots
N/A — no UI change.
Type
Notes
One of a set of draft PRs, each carrying a single defect the Quint model for org budgets surfaced, together with the reproduction test that was already committed but skipped.
🤖 Generated with Claude Code
Enterprise server image for this PR: