fix(purchases): atomic CAS cancel to close TOCTOU with concurrent approve (closes #671) - #674
Conversation
|
Warning Rate limit exceeded
You’ve run out of usage credits. Purchase more in the billing tab. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (13)
📝 WalkthroughWalkthroughThis PR introduces atomic execution cancellation to prevent a TOCTOU race where concurrent approve and cancel operations can silently overwrite an already-approved purchase status with "cancelled". Both session-authenticated (handler) and token-authenticated (manager) cancel paths now use conditional database updates instead of unconditional persistence. ChangesAtomic Execution Cancellation to Fix TOCTOU Race
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly Related PRs
Suggested Labels
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
internal/api/handler_purchases_test.go (1)
1607-1624: ⚡ Quick winAssert suppression cleanup on successful atomic cancel.
This helper now pins
CancelExecutionAtomic, but it doesn’t also pin the success-side suppression cleanup. Add an explicitDeleteSuppressionsByExecutionTxexpectation + assertion here so the transactional contract is fully covered.♻️ Suggested test tightening
func runSessionCancelAllowed(t *testing.T, exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) { t.Helper() handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, hasAny, hasOwn) + mockConfig.On("DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, cancelExecID). + Return(nil) + var capturedCancelledBy *string mockConfig.On("CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything). Run(func(args mock.Arguments) { if v, ok := args.Get(3).(*string); ok { capturedCancelledBy = v } }). Return(true, "cancelled", nil) result, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "") require.NoError(t, err) assert.Equal(t, "cancelled", result.(map[string]string)["status"]) mockConfig.AssertCalled(t, "CancelExecutionAtomic", mock.Anything, mock.Anything, cancelExecID, mock.Anything) + mockConfig.AssertCalled(t, "DeleteSuppressionsByExecutionTx", mock.Anything, mock.Anything, cancelExecID)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/api/handler_purchases_test.go` around lines 1607 - 1624, The test sets an expectation for CancelExecutionAtomic but misses asserting the corresponding success-path suppression cleanup; add a mock expectation on mockConfig for DeleteSuppressionsByExecutionTx (matching the same transaction/context and cancelExecID) before invoking handler.cancelPurchase, return a successful result from that mock, and after the call assert that mockConfig.AssertCalled(t, "DeleteSuppressionsByExecutionTx", ...) was invoked so the transactional contract between CancelExecutionAtomic and DeleteSuppressionsByExecutionTx is fully covered.internal/purchase/approvals_test.go (1)
693-697: ⚡ Quick winThe race test should explicitly pin “no cleanup write” behavior.
The test currently verifies CAS miss/error text, but not the suppression-cleanup non-call. Add an explicit negative assertion for
DeleteSuppressionsByExecutionTx(and make the purchase mock record that call path if needed) so this regression can’t silently drift.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/purchase/approvals_test.go` around lines 693 - 697, Add an explicit negative expectation that DeleteSuppressionsByExecutionTx was NOT called in the race test: after exercising the in-flight approve path and before/after store.AssertExpectations(t), add an assertion on the purchase mock (or store mock) to Verify/Assert that DeleteSuppressionsByExecutionTx was never invoked, and if the mock currently doesn't record that method path, update the purchase mock to include a no-call expectation/recorder for DeleteSuppressionsByExecutionTx so the test fails if a cleanup write occurs; keep the existing CAS miss/error assertions and CancelExecutionAtomic assertions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/handler_purchases.go`:
- Around line 536-548: The transaction error is being translated into a 409
client error; only the !cancelled path should return a 409. Change the outer
error handling for h.config.WithTx(...) so that if WithTx returns an error
(i.e., CancelExecutionAtomic or DeleteSuppressionsByExecutionTx failed inside
the transaction) you return/bubble an internal server error (5xx) or the
original error instead of NewClientError(409,...). Keep the existing
NewClientError(409,...) solely for the case when cancelled == false (the
concurrency/conflict case) and let real storage/tx errors surface as
server/internal errors.
---
Nitpick comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 1607-1624: The test sets an expectation for CancelExecutionAtomic
but misses asserting the corresponding success-path suppression cleanup; add a
mock expectation on mockConfig for DeleteSuppressionsByExecutionTx (matching the
same transaction/context and cancelExecID) before invoking
handler.cancelPurchase, return a successful result from that mock, and after the
call assert that mockConfig.AssertCalled(t, "DeleteSuppressionsByExecutionTx",
...) was invoked so the transactional contract between CancelExecutionAtomic and
DeleteSuppressionsByExecutionTx is fully covered.
In `@internal/purchase/approvals_test.go`:
- Around line 693-697: Add an explicit negative expectation that
DeleteSuppressionsByExecutionTx was NOT called in the race test: after
exercising the in-flight approve path and before/after
store.AssertExpectations(t), add an assertion on the purchase mock (or store
mock) to Verify/Assert that DeleteSuppressionsByExecutionTx was never invoked,
and if the mock currently doesn't record that method path, update the purchase
mock to include a no-call expectation/recorder for
DeleteSuppressionsByExecutionTx so the test fails if a cleanup write occurs;
keep the existing CAS miss/error assertions and CancelExecutionAtomic assertions
unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 83d37202-ae81-494a-a117-e143fee86af1
📒 Files selected for processing (13)
internal/analytics/collector_test.gointernal/api/handler_purchases.gointernal/api/handler_purchases_test.gointernal/api/mocks_test.gointernal/config/interfaces.gointernal/config/store_postgres.gointernal/mocks/stores.gointernal/purchase/approvals.gointernal/purchase/approvals_test.gointernal/purchase/coverage_extra_test.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/test_helpers_test.go
…rove (closes #671) Both cancel paths (session-authed dashboard cancel and email-token CancelExecution) used an out-of-transaction IsCancelable check followed by an unconditional SavePurchaseExecutionTx upsert. A concurrent approve that transitioned the row to 'approved' between the check and the upsert would be silently overwritten, leaving the DB at 'cancelled' while the AWS/Azure/GCP commitment is real and billable. Add CancelExecutionAtomic(ctx, tx, executionID, cancelledBy) to the store layer (interface + PostgresStore + all three mocks). The method issues a conditional UPDATE WHERE status IN ('pending','notified') inside the caller-provided tx, returning (false, currentStatus, nil) on zero rows affected so callers can 409 with the racing status visible. On success it returns (true, "cancelled", nil). Update both cancel paths to call CancelExecutionAtomic inside WithTx (paired with DeleteSuppressionsByExecutionTx on the success branch) and return 409 / a descriptive error when the CAS loses the race. Fix the misleading "optimistic-locking guard inside the tx" comment on handler_purchases.go that claimed a guard existed when it did not. Regression tests added for both paths: - TestManager_CancelExecution_RaceWithApprove (token path, purchase pkg) - TestHandler_cancelPurchase_Session_RaceWithApprove (session path, api pkg) Update all existing cancel tests to assert CancelExecutionAtomic rather than SavePurchaseExecution to reflect the new call site.
…sertions - handler_purchases.go: return fmt.Errorf (5xx) for WithTx failures instead of NewClientError(409); only the !cancelled path is a true conflict - handler_purchases_test.go: add DeleteSuppressionsByExecutionTx expectation + AssertCalled in runSessionCancelAllowed to cover the transactional contract - purchase/mocks_test.go: make DeleteSuppressionsByExecutionTx use m.Called so testify tracks calls for AssertNotCalled/AssertExpectations - approvals_test.go, coverage_extra_test.go: register the expectation on all success-path cancel tests; add AssertNotCalled on race test to guard against accidental cleanup writes when CAS misses Addresses CodeRabbit review on PR #674.
aad614b to
892b13d
Compare
CR follow-up (commit 892b13d)
All 1892 tests pass; @coderabbitai review |
|
Triggering a review of commit ✅ Actions performedReview triggered.
|
Summary
cancelPurchaseViaSessioninhandler_purchases.goandCancelExecutioninapprovals.go) had a TOCTOU race: an out-of-transactionIsCancelable()check followed by an unconditionalSavePurchaseExecutionTxupsert. A concurrent approve that transitioned the row toapprovedbetween the check and the upsert would be silently overwritten, leaving the DB atcancelledwhile the AWS/Azure/GCP commitment is real and billable.ApproveAndExecute) already used the atomicTransitionExecutionStatusCAS guard; cancel now uses the same pattern via a newCancelExecutionAtomicstore method.handler_purchases.go:524(which claimed a guard existed when it did not) is replaced with an accurate description.What changed
internal/config/store_postgres.go- NewCancelExecutionAtomic(ctx, tx, executionID, cancelledBy)method: conditionalUPDATE ... WHERE status IN ('pending','notified')returning(cancelled bool, currentStatus string, err error). Zero rows affected -> follow-up SELECT to surface the racing status.internal/config/interfaces.go- AddCancelExecutionAtomictoStoreInterface.internal/api/handler_purchases.go-cancelPurchaseViaSession: replace unconditionalSavePurchaseExecutionTxwithCancelExecutionAtomicinsideWithTx; 409 when CAS returnscancelled=falsewith the racing status in the body. Fix misleading comment.internal/purchase/approvals.go-CancelExecution: same replacement. Returns a descriptive error with the racing status on CAS miss.internal/mocks/stores.go,internal/api/mocks_test.go,internal/purchase/mocks_test.go,internal/scheduler/scheduler_test.go,internal/server/test_helpers_test.go,internal/analytics/collector_test.go- AddCancelExecutionAtomicto all mock implementations that satisfyStoreInterface.internal/purchase/approvals_test.go,internal/api/handler_purchases_test.go,internal/purchase/coverage_extra_test.go- Update existing cancel tests to assert onCancelExecutionAtomicinstead ofSavePurchaseExecution. Add two new regression tests:TestManager_CancelExecution_RaceWithApprove(token path)TestHandler_cancelPurchase_Session_RaceWithApprove(session path)Test plan
go test ./internal/api/... ./internal/purchase/... ./internal/config/... ./internal/scheduler/... ./internal/server/... ./internal/analytics/... -count=1 -short- 2479 tests passgo build ./...- clean buildgo vet ./...- no issuesTestManager_CancelExecution_RaceWithApproveconfirms the token path returns a descriptive error when the CAS losesTestHandler_cancelPurchase_Session_RaceWithApproveconfirms the session path returns 409 with racing status in bodyCloses #671
Refs #632, #667, #669
Summary by CodeRabbit