Skip to content

fix(purchases): gate scheduled-purchase management on creator-scope ownership (closes #950) - #995

Merged
cristim merged 5 commits into
feat/multicloud-web-frontendfrom
sec/950-scheduled-purchase-ownership-gate
Jun 6, 2026
Merged

fix(purchases): gate scheduled-purchase management on creator-scope ownership (closes #950)#995
cristim merged 5 commits into
feat/multicloud-web-frontendfrom
sec/950-scheduled-purchase-ownership-gate

Conversation

@cristim

@cristim cristim commented Jun 5, 2026

Copy link
Copy Markdown
Member

Summary

Closes #950. A standard user holding the default update:purchases verb could pause / resume (and, via custom groups, run / delete) scheduled purchases created by other users. The planned-purchase management handlers checked only the verb and account scope, never per-record ownership: a true authz / separation-of-duties hole (QA row 552, P1 / severity-high).

This applies the creator-scope model already used on Purchase History (cancel-own / approve-own / retry-own): manage a scheduled purchase only if you created it, OR you hold the privileged update-any:purchases verb (admins via {admin, *}).

Backend

  • internal/auth/types.go: add ActionUpdateAny (update-any:purchases). Not in adminCarvedOuts, so admins pass via the {admin, *} wildcard; no default non-admin grant.
  • internal/api/handler_purchases.go: add Handler.authorizeExecutionManagement (mirrors authorizeSessionCancel/approve/retry): allow the stateless admin key, an update-any holder, or the row creator (non-nil CreatedByUserID == non-empty session.UserID); else 403; nil auth is 500 (fail closed). Legacy NULL-creator rows are unreachable for non-update-any users. Wired into pause / resume / run / delete after the existing verb + account-scope checks (run/delete keep their stricter execute/delete verb gates).
  • Surface created_by_user_id on the PlannedPurchase DTO (mirrors PurchaseHistoryRecord.CreatedByUserID).

Frontend

  • api/types.ts: add created_by_user_id? to PlannedPurchase; permissions.ts: add the update-any Action.
  • plans.ts: add canManageScheduledPurchase() and AND it into the Scheduled Purchases row-button gates. A non-creator without update-any now sees no action buttons, matching the backend and the QA Expected Result.

Tests (fail pre-fix, pass post-fix)

  • Backend (handler_purchases_test.go): user A pausing/resuming/deleting/running user B's execution -> 403 with no status transition; owner manages own -> success; update-any holder manages any -> success; nil-auth 500; legacy NULL-creator denied. The existing feat(api/auth): finer-grained per-role write permissions across plans/purchases/RI-exchange #660 permission-flip tests were updated to assert the new ownership model (added explicit non-creator-403 and update-any-allow cases). Verified the new non-owner tests FAIL against the pre-fix handler.
  • Frontend (plans-ownership-950.test.ts): non-creator with the same verbs renders no row buttons; creator and update-any holder do; legacy NULL-creator row shows none. Verified the discriminating cases FAIL against the pre-fix render.

Local: go build ./... OK, go test ./internal/api/ 1475 pass, ./internal/config/... 559 pass; FE npm run build OK, full jest 2362 pass.

Summary by CodeRabbit

  • New Features

    • Added creator-based ownership gating for scheduled purchases; users can now only manage their own scheduled purchases unless they have the "update-any purchases" permission.
    • Introduced new privilege level allowing authorized users to manage any scheduled purchase regardless of ownership.
  • Bug Fixes

    • Fixed conflict retry logic for scheduled purchase deletion to properly handle status mismatches.

…wnership (closes #950)

A standard user holding the default update:purchases verb could pause,
resume, run, or delete scheduled purchases created by OTHER users: the
planned-purchase management handlers checked only the verb and account
scope, never per-record ownership. This is an integrity / separation-of-
duties hole (QA row 552, P1 / severity-high).

Backend:
- Add auth.ActionUpdateAny ("update-any:purchases"), the privileged escape
  that lets a holder manage any user's scheduled purchase. Not in
  adminCarvedOuts, so admins pass via {admin, *}.
- Add Handler.authorizeExecutionManagement enforcing the creator-scope
  model (mirrors authorizeSessionCancel / approve / retry): allow when the
  caller is the stateless admin key, holds update-any, or is the row's
  creator (non-nil CreatedByUserID == non-empty session.UserID). Otherwise
  403; nil auth is 500 (fail closed). Legacy NULL-creator rows are out of
  reach for non-update-any users.
- Wire the gate into pause / resume / run / delete after the existing verb
  and account-scope checks (run/delete keep their stricter execute/delete
  verb gates).
- Surface created_by_user_id on the PlannedPurchase DTO so the client can
  gate ownership.

Frontend:
- Add created_by_user_id to the PlannedPurchase type and the 'update-any'
  Action.
- Add canManageScheduledPurchase() and AND it into the Scheduled Purchases
  row-button gates: a non-creator without update-any sees no action
  buttons, matching the backend.

Tests (fail pre-fix, pass post-fix):
- Backend: user A pausing/resuming/deleting/running user B's execution ->
  403 with no status transition; owner manages own -> success; update-any
  holder manages any -> success; nil-auth 500; legacy NULL-creator denied.
  Updated the #660 permission-flip tests to assert the new ownership model.
- Frontend: non-creator with the same verbs renders no row buttons; creator
  and update-any holder do; legacy NULL-creator row shows none.
@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

Ready to act? Review this PR in Change Stack to turn feedback into patch suggestions you can inspect and refine.

Review Change Stack

Warning

Review limit reached

@cristim, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 36 minutes and 2 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 853bfa3d-9a66-42ed-8fda-35754e59b103

📥 Commits

Reviewing files that changed from the base of the PR and between 1f66f71 and 94326f6.

📒 Files selected for processing (10)
  • frontend/src/__tests__/dashboard-ownership-950.test.ts
  • frontend/src/__tests__/dashboard.test.ts
  • frontend/src/api/types.ts
  • frontend/src/dashboard.ts
  • frontend/src/types.ts
  • internal/api/handler_dashboard.go
  • internal/api/handler_dashboard_test.go
  • internal/api/handler_plans.go
  • internal/api/handler_plans_test.go
  • internal/api/types.go
📝 Walkthrough

Walkthrough

This PR implements creator-ownership enforcement for scheduled-purchase management across frontend UI and backend authorization. Users can only manage their own scheduled purchases unless they hold the new update-any:purchases permission. Both frontend and backend validate ownership; the API exposes created_by_user_id for UI gating, and a new authorizeExecutionManagement helper enforces ownership on pause/resume/run/delete endpoints.

Changes

Creator-Ownership Gating for Scheduled Purchases

Layer / File(s) Summary
Type and permission contracts
frontend/src/api/types.ts, frontend/src/permissions.ts, internal/api/types.go, internal/auth/types.go
Frontend and backend API types add created_by_user_id / CreatedByUserID fields for ownership tracking; permission system adds new update-any action and ActionUpdateAny constant with documentation.
Backend authorization and payload enforcement
internal/api/handler_purchases.go
buildPlannedPurchase populates CreatedByUserID on responses; new authorizeExecutionManagement centralizes ownership validation with admin-key and update-any bypasses; all four management handlers (pause/resume/run/delete) call the auth check; deletePlannedPurchase refactored to use cancelOrRecoverExecution for idempotent cancellation.
Frontend ownership gating and row rendering
frontend/src/plans.ts
New canManageScheduledPurchase helper checks creator ownership and session capabilities; renderPlannedPurchaseRow gates all action buttons (run/pause/resume/edit/disable) on both ownership and verb permissions.
Frontend ownership test suite
frontend/src/__tests__/plans-ownership-950.test.ts
Comprehensive Jest tests covering creator-owned row management, non-owner button suppression, update-any:purchases privilege bypass, and legacy NULL-creator scenarios with mocked API/state.
Backend handler and authorization tests
internal/api/handler_purchases_test.go
Verifies non-owner 403 rejection, owner success and status transitions, update-any bypass, CR #995 conflict-retry idempotency, and authorization helper fail-closed behavior.
Router permission-gate integration tests
internal/api/router_660_permission_flips_test.go
Updates pause/delete permission-gate tests to cover creator-scope ownership, non-creator 403 rejection, and update-any privilege bypass.
Test infrastructure: group membership updates
frontend/src/__tests__/plans-permissions.test.ts, frontend/src/__tests__/plans.test.ts
Admin users in test mocks now include both ADMINISTRATORS and PURCHASER group IDs for correct permission evaluation.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • LeanerCloud/CUDly#922: Adds effectivePermissions-backed permission checks on the frontend; the new ownership gating and update-any verb behavior depend on this permission-evaluation pathway.

Suggested labels

effort/m

Poem

🐰 A purchase plan, once free to roam,
Now knows whose hands have led it home—
Ownership claimed, permissions tight,
Each action gated left and right!
No more can strangers pause thy quest,
update-any grants the rest. 🌟

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 70.59% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: gating scheduled-purchase management on creator-scope ownership to close issue #950. It is clear, specific, and reflects the primary objective.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from issue #950: backend ownership gate via authorizeExecutionManagement, created_by_user_id API exposure, frontend canManageScheduledPurchase gating, new update-any verb, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are scope to issue #950 and PR #995. Minor test fixture updates (adding PURCHASER_GROUP_ID to admin mocks) align with the ownership-gating feature and maintain correct permission contexts for testing.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sec/950-scheduled-purchase-ownership-gate

Comment @coderabbitai help to get the list of available commands and usage tips.

@cristim cristim added triaged Item has been triaged priority/p1 Next up; this sprint severity/high Significant harm urgency/this-sprint Within the current sprint impact/many Affects most users type/security Security finding labels Jun 5, 2026
@cristim

cristim commented Jun 5, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 5, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

)

The new authorize/disable wiring in deletePlannedPurchase nudged it
from complexity 10 to 11, failing pre-commit's gocyclo > 10 ceiling.

Split the cancel-or-recover idempotency block (TransitionExecutionStatus
plus the post-conflict GetExecutionByID fallback) into a focused helper.
The main function now reads top-to-bottom as validate -> authorize ->
cancel-or-recover -> disable-plan, and the helper carries the four
decision points that previously lived inline.

Behaviour unchanged; 1475 tests pass in internal/api.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/api/handler_purchases.go (1)

343-381: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Only recover idempotently from an already-cancelled row.

Lines 371-381 treat every ErrExecutionNotInExpectedStatus as a successful recovery. If the execution raced to running, completed, or failed, deletePlannedPurchase() still disables the parent plan and returns "cancelled" even though no cancel happened. The recovery path should only succeed when the fetched row is already "cancelled"; otherwise keep the 409.

Suggested fix
 func (h *Handler) cancelOrRecoverExecution(ctx context.Context, executionID string) (*config.PurchaseExecution, error) {
 	cancelled, err := h.config.TransitionExecutionStatus(ctx, executionID, []string{"pending", "paused"}, "cancelled")
 	if err == nil {
 		return cancelled, nil
 	}
 	if !errors.Is(err, config.ErrExecutionNotInExpectedStatus) {
 		return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be cancelled: %v", executionID, err))
 	}
 	existing, getErr := h.config.GetExecutionByID(ctx, executionID)
 	if getErr != nil {
 		return nil, fmt.Errorf("disable plan: failed to get execution %s after conflict: %w", executionID, getErr)
 	}
 	if existing == nil {
 		return nil, NewClientError(404, fmt.Sprintf("execution %s not found", executionID))
 	}
+	if existing.Status != "cancelled" {
+		return nil, NewClientError(409, fmt.Sprintf(
+			"execution %s cannot be cancelled (status=%s)",
+			executionID,
+			existing.Status,
+		))
+	}
 	return existing, nil
 }
🤖 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.go` around lines 343 - 381, The recovery path
in cancelOrRecoverExecution incorrectly treats any
ErrExecutionNotInExpectedStatus as success, which can falsely return a
non-cancelled execution and cause deletePlannedPurchase/disablePlan to run;
change the logic so that after calling h.config.GetExecutionByID(ctx,
executionID) you only return the existing execution if existing.Status ==
"cancelled" (or the canonical constant used for cancelled), otherwise propagate
a 409 client error (same style as NewClientError used when
TransitionExecutionStatus fails) indicating the execution cannot be cancelled;
keep the existing checks for nil existing and the 404 behavior.
frontend/src/plans.ts (1)

697-727: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Gate each row button with the verb its endpoint actually requires.

Lines 703-727 still use update/delete:plans for Run/Pause/Resume/Disable, but the backend handlers enforce execute/update/delete:purchases. That means the scheduled-purchase table can still show buttons that 403 on click, and it can also hide buttons from roles that legitimately have purchases-scoped access without plan-edit rights. Keep the ownership check, but split the verb gates per action; only Edit should stay on update:plans.

Suggested fix
-  const isOwner = canManageScheduledPurchase(purchase);
-  const canManagePlan = canAccess('update', 'plans') && isOwner;
-  const canDisablePlan = canAccess('delete', 'plans') && isOwner;
+  const canManagePurchase = canManageScheduledPurchase(purchase);
+  const canRunPurchase = canManagePurchase && canAccess('execute', 'purchases') && canRun;
+  const canPauseOrResumePurchase = canManagePurchase && canAccess('update', 'purchases');
+  const canEditPlan = canManagePurchase && canAccess('update', 'plans');
+  const canDisablePlan = canManagePurchase && canAccess('delete', 'purchases');
@@
-        ${canManagePlan && canRun ? `<button data-action="run" data-id="${purchase.id}" class="btn-small primary" title="Run now">▶</button>` : ''}
-        ${canManagePlan && isPending ? `<button data-action="pause" data-id="${purchase.id}" class="btn-small" title="Pause">⏸</button>` : ''}
-        ${canManagePlan && isPaused ? `<button data-action="resume" data-id="${purchase.id}" class="btn-small" title="Resume">⏵</button>` : ''}
-        ${canManagePlan ? `<button data-action="edit" data-id="${purchase.id}" data-plan-id="${purchase.plan_id}" class="btn-small" title="Edit Plan">✎</button>` : ''}
+        ${canRunPurchase ? `<button data-action="run" data-id="${purchase.id}" class="btn-small primary" title="Run now">▶</button>` : ''}
+        ${canPauseOrResumePurchase && isPending ? `<button data-action="pause" data-id="${purchase.id}" class="btn-small" title="Pause">⏸</button>` : ''}
+        ${canPauseOrResumePurchase && isPaused ? `<button data-action="resume" data-id="${purchase.id}" class="btn-small" title="Resume">⏵</button>` : ''}
+        ${canEditPlan ? `<button data-action="edit" data-id="${purchase.id}" data-plan-id="${purchase.plan_id}" class="btn-small" title="Edit Plan">✎</button>` : ''}
         ${canDisablePlan ? `<button data-action="disable" data-id="${purchase.id}" class="btn-small danger" title="Disable Plan">✕</button>` : ''}
🤖 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 `@frontend/src/plans.ts` around lines 697 - 727, The row action gating
currently reuses canManagePlan/canDisablePlan (update/delete:plans) but the
backend requires purchases-scoped verbs; introduce per-action guards: keep
isOwner = canManageScheduledPurchase(purchase), then add canExecutePurchase =
canAccess('execute','purchases') && isOwner, canUpdatePurchase =
canAccess('update','purchases') && isOwner, and canDeletePurchase =
canAccess('delete','purchases') && isOwner; leave the Edit button using
canAccess('update','plans') && isOwner. Update the template boolean checks so:
Run uses canExecutePurchase, Pause/Resume use canUpdatePurchase, Edit stays
using update:plans, and Disable uses canDeletePurchase (referencing variables
canExecutePurchase, canUpdatePurchase, canDeletePurchase, canAccess,
canManageScheduledPurchase, and the buttons with
data-action="run"/"pause"/"resume"/"edit"/"disable").
🧹 Nitpick comments (1)
internal/api/router_660_permission_flips_test.go (1)

237-254: 💤 Low value

Consider setting CreatedByUserID to a different user for clarity.

The test correctly verifies the update-any bypass, but the mock returns an execution with CreatedByUserID unset (nil). While this is valid because authorizeExecutionManagement short-circuits before checking ownership when update-any is true, explicitly setting CreatedByUserID to a different user (e.g., otherCreator) would make the "can manage another user's execution" intent clearer.

🔍 Optional clarification
+		otherCreator := "99999999-9999-9999-9999-999999999999"
 		mockStore.On("GetExecutionByID", ctx, execID).
-			Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending"}, nil)
+			Return(&config.PurchaseExecution{ExecutionID: execID, Status: "pending", CreatedByUserID: &otherCreator}, nil)
🤖 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/router_660_permission_flips_test.go` around lines 237 - 254,
Update the test's mocked execution returned by GetExecutionByID to set
CreatedByUserID to a different user (e.g., "otherCreator") so the intent that
update-any bypasses ownership is explicit; modify the mocked return value for
config.PurchaseExecution in the Test block (the call to
mockStore.On("GetExecutionByID", ctx, execID).Return(...)) used by
Handler.pausePlannedPurchase and authorizeExecutionManagement to include
CreatedByUserID:"otherCreator".
🤖 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 `@frontend/src/__tests__/plans-ownership-950.test.ts`:
- Around line 152-161: The test is using othersPurchase (created with OTHER_ID)
while the session is also set to OTHER_ID, so it isn't exercising "another
user's" purchase; change the fixture so the purchase's creator is a different id
(e.g., CREATOR_ID) while keeping setUser(OTHER_ID, { updateAny: true }), i.e.,
create a purchase object whose creator/owner field is CREATOR_ID (not OTHER_ID)
and return that from the mocked api.getPlannedPurchases used by
loadPlans()/ppHtml(), so the test named "update-any holder sees buttons on
another user's scheduled purchase" actually verifies updateAny bypass for other
users' purchases.

---

Outside diff comments:
In `@frontend/src/plans.ts`:
- Around line 697-727: The row action gating currently reuses
canManagePlan/canDisablePlan (update/delete:plans) but the backend requires
purchases-scoped verbs; introduce per-action guards: keep isOwner =
canManageScheduledPurchase(purchase), then add canExecutePurchase =
canAccess('execute','purchases') && isOwner, canUpdatePurchase =
canAccess('update','purchases') && isOwner, and canDeletePurchase =
canAccess('delete','purchases') && isOwner; leave the Edit button using
canAccess('update','plans') && isOwner. Update the template boolean checks so:
Run uses canExecutePurchase, Pause/Resume use canUpdatePurchase, Edit stays
using update:plans, and Disable uses canDeletePurchase (referencing variables
canExecutePurchase, canUpdatePurchase, canDeletePurchase, canAccess,
canManageScheduledPurchase, and the buttons with
data-action="run"/"pause"/"resume"/"edit"/"disable").

In `@internal/api/handler_purchases.go`:
- Around line 343-381: The recovery path in cancelOrRecoverExecution incorrectly
treats any ErrExecutionNotInExpectedStatus as success, which can falsely return
a non-cancelled execution and cause deletePlannedPurchase/disablePlan to run;
change the logic so that after calling h.config.GetExecutionByID(ctx,
executionID) you only return the existing execution if existing.Status ==
"cancelled" (or the canonical constant used for cancelled), otherwise propagate
a 409 client error (same style as NewClientError used when
TransitionExecutionStatus fails) indicating the execution cannot be cancelled;
keep the existing checks for nil existing and the 404 behavior.

---

Nitpick comments:
In `@internal/api/router_660_permission_flips_test.go`:
- Around line 237-254: Update the test's mocked execution returned by
GetExecutionByID to set CreatedByUserID to a different user (e.g.,
"otherCreator") so the intent that update-any bypasses ownership is explicit;
modify the mocked return value for config.PurchaseExecution in the Test block
(the call to mockStore.On("GetExecutionByID", ctx, execID).Return(...)) used by
Handler.pausePlannedPurchase and authorizeExecutionManagement to include
CreatedByUserID:"otherCreator".
🪄 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: f2a495f2-6a60-409f-b9fa-2819df432fa9

📥 Commits

Reviewing files that changed from the base of the PR and between 8ab0d20 and dd2cc1c.

📒 Files selected for processing (9)
  • frontend/src/__tests__/plans-ownership-950.test.ts
  • frontend/src/api/types.ts
  • frontend/src/permissions.ts
  • frontend/src/plans.ts
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/router_660_permission_flips_test.go
  • internal/api/types.go
  • internal/auth/types.go

Comment thread frontend/src/__tests__/plans-ownership-950.test.ts
- cancelOrRecoverExecution: only treat fetched row as recovered when
  status is actually "cancelled"; return 409 with actual status
  otherwise, catching races to running/completed/failed.
  Regression test added (ConflictRetryRunningReturns409).
- plans.ts row buttons: gate on the verbs the backend actually checks
  (execute/update/delete:purchases), keeping Edit on update:plans.
  Update test fixtures to include execute:purchases and delete:purchases
  verbs and PURCHASER_GROUP_ID where needed for the run/disable buttons.
- plans-ownership-950.test.ts: use ownedPurchase (created by CREATOR_ID)
  in the update-any bypass test so OTHER_ID session is actually not the
  creator, properly exercising the bypass; remove now-unused othersPurchase.
@cristim

cristim commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

Addressed CR pass-1 findings:

  • Major: cancelOrRecoverExecution now guards on existing.Status == "cancelled" after the ErrExecutionNotInExpectedStatus fallback; returns 409 with the actual status otherwise (catches races to running/completed/failed). Regression test added: TestHandler_deletePlannedPurchase_ConflictRetryRunningReturns409.
  • Major: plans.ts row buttons now gate on the verbs the backend actually checks (execute/update/delete:purchases), keeping Edit on update:plans. Test fixtures updated to include execute:purchases, delete:purchases, and PURCHASER_GROUP_ID where needed.
  • Critical: plans-ownership-950.test.ts "update-any holder" test now uses ownedPurchase (created by CREATOR_ID) so the OTHER_ID session is actually not the creator, properly exercising the update-any bypass. Removed now-unused othersPurchase fixture.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the detailed summary of the fixes — the 409 conflict guard, verb alignment, and the update-any fixture correction all look like solid improvements. Triggering a new review pass now.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-05T08:48:15.584Z
Learning: In LeanerCloud/CUDly PR `#804`, `callAzureReturn` in `internal/api/handler_purchases_revoke.go` rejects an order-only ARM path (empty `reservationID`) with a 422 instead of submitting an empty Return call to Azure.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 507
File: frontend/src/app.ts:388-395
Timestamp: 2026-05-20T08:26:42.493Z
Learning: In `frontend/src/app.ts` (CUDly project), `openArcheraOfferModal('purchase')` is intentionally called immediately after the `executePurchase` approval-submission API call returns success (in `handleExecutePurchase` and `handleFanOutExecute`), NOT after the async email-link execution completes. This timing was explicitly revised from the original issue `#499` requirement. The offer is gated on the submission success path only (never on failure/throw), and the fan-out path requires at least one successful submission (`succeeded > 0`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 507
File: frontend/src/app.ts:388-395
Timestamp: 2026-05-20T08:26:36.680Z
Learning: In `frontend/src/app.ts` (CUDly project), `openArcheraOfferModal('purchase')` is intentionally called immediately after the `executePurchase` approval-submission API call returns success (in `handleExecutePurchase` and `handleFanOutExecute`), NOT after the async email-link execution completes. This timing was explicitly revised from the original issue `#499` requirement. The offer is gated on the submission success path only (never on failure/throw), and the fan-out path requires at least one successful submission (`succeeded > 0`).
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

…nt (closes #950 followup)

Adversarial review of the PR #995 ownership-gate fix uncovered that
POST /api/plans/{id}/purchases (createPlannedPurchases) never wrote
CreatedByUserID onto the rows it inserted. Every freshly scheduled
purchase therefore shipped a NULL creator, which the new per-row
ownership gate (authorizeExecutionManagement) treats as a legacy
unattributed row -- reachable only by admin / update-any holders.
Net effect pre-fix: a standard user clicks "Create planned purchases"
on their own plan and immediately loses the ability to pause / resume
/ run / delete the rows they just scheduled. The user-facing 403 is
indistinguishable from the original #950 bypass to a confused operator.

The scheduler-tick path in purchase/notifications.go legitimately stays
NULL-creator (no human session, by migration-000041 design); only the
session-driven explicit-create endpoint stamps the actor here.

Wire resolveCreatorUserID(session) through createPurchaseExecutionsTx
so each batch row carries the session UUID. Admin-API-key sessions
resolve to nil (the UserID sentinel is not a UUID), matching the
executePurchase / retry paths.

Regression tests:
- TestHandler_createPlannedPurchases_StampsCreator: captures
  every saved row's CreatedByUserID and asserts each one is the
  session user's UUID. Fails pre-fix on the first nil pointer.
- TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil:
  the admin-API-key path stays NULL so the FK to users remains
  valid; locks in the resolveCreatorUserID contract.
cristim added a commit that referenced this pull request Jun 6, 2026
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.
…hip (closes #950 followup)

Adversarial review of PR #995's frontend permission walk uncovered a
second site that bypassed the new ownership gate: the dashboard
"Upcoming purchases" widget. Both the card-level Cancel button and the
modal Cancel Purchase button in dashboard.ts called
api.deletePlannedPurchase(executionId) unconditionally, while the
backend now (correctly) 403s for non-owners after PR #995's
authorizeExecutionManagement gate. The Plans page hides those buttons
via canManageScheduledPurchase; the Dashboard widget rendered them for
everyone, so a standard user looking at another user's scheduled
purchase saw an actionable Cancel button and got a confusing 403
toast on click.

The UpcomingPurchase backend struct also lacked CreatedByUserID, so
the frontend had no way to apply the gate even if it wanted to. Fix
spans backend (struct + projection) and frontend (types + render).

Backend:
- Add CreatedByUserID *string with omitempty json tag to the
  internal/api/types.UpcomingPurchase response struct.
- Populate from exec.CreatedByUserID in upcomingFromExecution
  (handler_dashboard.go).

Frontend:
- Add created_by_user_id?: string to UpcomingPurchase in both
  frontend/src/api/types.ts and frontend/src/types.ts (the latter is
  the local dashboard-side view).
- New canCancelUpcomingPurchase() helper in dashboard.ts mirrors
  plans.ts's canManageScheduledPurchase: admin / update-any sees
  Cancel on any row; otherwise created_by_user_id must match the
  current user; legacy NULL-creator rows hide for non-privileged
  users. Additionally requires the base delete:purchases verb so a
  read-only user never sees the button even on their own row.
- Gate the card-level Cancel button (renderUpcomingPurchases) and
  the modal Cancel Purchase button (buildUpcomingDetailsModal) on
  the new helper. View Details stays unrestricted -- only the
  destructive action needs the gate.

Regression tests:
- frontend/__tests__/dashboard-ownership-950.test.ts (new):
  drives loadDashboard end-to-end across the {owner, non-owner,
  update-any, admin, legacy-NULL, no-delete-verb} matrix.
- TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID:
  locks in the backend struct propagation -- owned rows ship the
  UUID, scheduler-tick NULL rows stay nil (omitempty so the JSON
  wire shape is unchanged on legacy rows).
- Existing dashboard.test.ts cancel-flow tests now mock
  state.getCurrentUser as an admin so the click path stays exercised
  end-to-end; the dedicated ownership tests above cover the gate
  logic on its own.
@cristim

cristim commented Jun 6, 2026

Copy link
Copy Markdown
Member Author

Adversarial review pass on this PR's ownership-gate logic. Two real defects beyond CR's first round, both addressed in-place:

  • POST /api/plans/{id}/purchases didn't stamp created_by_user_id. Every scheduled purchase created via the explicit Plans-page action shipped a NULL creator, locking the user who just clicked "Create planned purchases" out of pause/resume/run/delete on their own rows until an admin stepped in. Threaded resolveCreatorUserID(session) through createPurchaseExecutionsTx. Commit 9ef1b4352. Regression: TestHandler_createPlannedPurchases_StampsCreator + TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil.

  • Dashboard "Upcoming purchases" widget bypassed the new ownership gate. Both the card-level Cancel button and the modal Cancel Purchase button called api.deletePlannedPurchase for any row, while the backend now 403s for non-owners. The UpcomingPurchase response struct also lacked CreatedByUserID, so the frontend had no field to drive a gate. Added the field on both sides + a canCancelUpcomingPurchase helper that mirrors canManageScheduledPurchase on the Plans page. Commit 94326f6b9. Regression: 6 new tests in frontend/src/__tests__/dashboard-ownership-950.test.ts + TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID.

Surfaces walked with no further findings:

  • List endpoint /api/purchases/planned filters by plan-access scope (correct); IDs of non-owned rows are visible by design but the backend gate is the boundary. No bulk pause/run/delete endpoints exist. No "cancelling" intermediate status. RBAC test matrix on pause/resume/run/delete is complete via router_660_permission_flips_test.go + the buildManageHandler matrix in handler_purchases_test.go.
  • Frontend Action is a closed TS enum so verb typos fail compile; no other purchase-action call sites in inventory.ts, recommendations.ts, or purchases-deeplink.ts besides plans.ts and dashboard.ts. Tests fixtures (post-CR-pass-1) are self-consistent.
  • created_by_user_id data integrity (legacy NULL + ON DELETE SET NULL + nullable column) matches the documented cancel-own / approve-own / retry-own model per migration 000041 -- no scope creep.

Noted but deferred as out-of-scope for #950 (pre-existing or design calls):

  • TransitionExecutionStatus does not write cancelled_by on the scheduled-purchase delete path -- attribution gap that pre-dates this PR.
  • deletePlan cascade can remove a plan whose scheduled purchases include rows owned by other users -- but only via delete:plans, which is a plan-level verb separate from the per-purchase model the issue scopes.

Full test pass: go test ./internal/api/... -> 1479 passing (1475 baseline + 3 + 1); npm test -> 2368 passing.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

@cristim Excellent adversarial pass — both findings are high-impact catches:

  1. Create path NULL stamping is the kind of defect that would have silently invalidated the entire ownership gate for all new rows going forward, since the NULL-creator "legacy" path intentionally denies non-update-any users. The resolveCreatorUserID(session) threading and admin-key nil-branch regression look exactly right.

  2. Dashboard widget surface is a classic "security control applied to one entry point but not all" gap. Propagating CreatedByUserID on UpcomingPurchase and mirroring canManageScheduledPurchase as canCancelUpcomingPurchase closes that cleanly.

The deferred items are correctly scoped out — cancelled_by attribution and deletePlan cascade are both pre-existing and involve separate verbs/concerns. Triggering the review pass now.

🐇 ✨


✏️ Learnings added
Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/995

Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), `POST /api/plans/{id}/purchases` (`createPurchaseExecutionsTx`) must stamp `created_by_user_id` via `resolveCreatorUserID(session)` at creation time. Without this, every scheduled purchase created via the Plans-page "Create planned purchases" action ships a NULL creator, which the ownership gate treats as a legacy row and denies management to all non-`update-any` users — including the creator themselves. The admin API key path must set `CreatedByUserID` to nil (not the key identity). Regressions: `TestHandler_createPlannedPurchases_StampsCreator` and `TestHandler_createPlannedPurchases_AdminAPIKeyCreatorIsNil`. Fixed in commit 9ef1b4352.

Learnt from: cristim
URL: https://github.com/LeanerCloud/CUDly/pull/995

Timestamp: 2026-06-06T06:43:53.770Z
Learning: In LeanerCloud/CUDly PR `#995` (issue `#950` ownership gate), the Dashboard "Upcoming purchases" widget (`dashboard.ts`) has two Cancel entry points (card-level and modal) that both call `api.deletePlannedPurchase`. The `UpcomingPurchase` response DTO must include `CreatedByUserID *string` (omitempty, mirroring `PlannedPurchase`) so the frontend can gate these buttons via a `canCancelUpcomingPurchase` helper that mirrors `canManageScheduledPurchase` on the Plans page. Without this, the backend correctly 403s non-owners but the UI has no gate and the UX is broken. Regressions: `frontend/src/__tests__/dashboard-ownership-950.test.ts` (6 tests) and `TestHandler_getUpcomingPurchases_PropagatesCreatedByUserID`. Fixed in commit 94326f6b9.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.830Z
Learning: In LeanerCloud/CUDly PR `#804`, `canRevokeCompletedRow` in `frontend/src/history.ts` must check `canAccess` (not just `getCurrentUser()` truthiness) to align with peer predicates `canCancelPendingRow`, `canApprovePendingRow`, and `canRetryFailedRow`. `revoke-own` and `revoke-any` were added to the closed `Action` union to make future UX-vs-RBAC drift a compile error.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.830Z
Learning: In LeanerCloud/CUDly PR `#804`, `CancelScheduledExecutionAtomic` (`WHERE status = 'scheduled'`) is distinct from `CancelExecutionAtomic` (`WHERE status IN ('pending','notified')`). The pre-fire-delay revoke path uses `CancelScheduledExecutionAtomic`; the 410 "window closed" / 409 "not pending" race outcomes must remain disambiguated. Using `CancelExecutionAtomic` for scheduled-execution revocation is a critical bug (the execution fires and the user is charged while the UI shows "cancelled").

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 507
File: frontend/src/app.ts:388-395
Timestamp: 2026-05-20T08:26:42.493Z
Learning: In `frontend/src/app.ts` (CUDly project), `openArcheraOfferModal('purchase')` is intentionally called immediately after the `executePurchase` approval-submission API call returns success (in `handleExecutePurchase` and `handleFanOutExecute`), NOT after the async email-link execution completes. This timing was explicitly revised from the original issue `#499` requirement. The offer is gated on the submission success path only (never on failure/throw), and the fan-out path requires at least one successful submission (`succeeded > 0`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 507
File: frontend/src/app.ts:388-395
Timestamp: 2026-05-20T08:26:36.680Z
Learning: In `frontend/src/app.ts` (CUDly project), `openArcheraOfferModal('purchase')` is intentionally called immediately after the `executePurchase` approval-submission API call returns success (in `handleExecutePurchase` and `handleFanOutExecute`), NOT after the async email-link execution completes. This timing was explicitly revised from the original issue `#499` requirement. The offer is gated on the submission success path only (never on failure/throw), and the fan-out path requires at least one successful submission (`succeeded > 0`).

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-05T08:48:15.584Z
Learning: In LeanerCloud/CUDly PR `#804`, `RevocationWindowClosesAt` is stamped at purchase-write time in `internal/purchase/execution.go` (`savePurchaseHistory`) via a shared helper `config.RevocationWindowClosesAtFor` and constant `config.AzureRevocationWindowDays`. The backend revoke handler (`revokeAzurePurchase`) reads the stamped column as the source of truth and falls back to recomputing from `Timestamp` only for legacy rows (where the column is NULL). The frontend gate `canRevokeCompletedRow` in `frontend/src/history.ts` depends on `revocation_window_closes_at` being present to show the Revoke button.

Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-06-06T06:41:06.830Z
Learning: In LeanerCloud/CUDly PR `#804`, the purchase-history revocation migration is numbered 000066 (`000066_purchase_history_revocation.{up,down}.sql`). It was renumbered multiple times due to collisions: 000057 → 000059 → 000060 → 000064 → 000065 → 000066. 000065 is occupied by `000065_enforce_min_one_admin` (merged via `#921`) on the base branch.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim
cristim merged commit cb1abb7 into feat/multicloud-web-frontend Jun 6, 2026
5 checks passed
@cristim
cristim deleted the sec/950-scheduled-purchase-ownership-gate branch June 6, 2026 06:53
cristim added a commit that referenced this pull request Jun 8, 2026
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.
cristim added a commit that referenced this pull request Jun 8, 2026
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.
cristim added a commit that referenced this pull request Jun 8, 2026
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.
cristim added a commit that referenced this pull request Jun 8, 2026
canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.
cristim added a commit that referenced this pull request Jun 9, 2026
…290) (#804)

* feat(purchases): in-app revocation within free-cancel window (closes #290)

POST /api/purchases/{purchaseId}/revoke: Azure returns via armreservations
(CalculateRefund + Return two-step), 7-day window. AWS/GCP return 422 and
the History UI hides the button for those providers.

- DB: migration 000057 adds revocation_window_closes_at, revoked_at,
  revoked_via, support_case_id columns to purchase_history
- RBAC: revoke-own / revoke-any actions, revoke-own granted to all users
  by default; ownership is via account-access (history rows pre-date
  created_by_user_id)
- Backend: fail-closed nil-auth guard, idempotency via revoked_at IS NULL,
  Azure CalculateRefund->Return with test-injectable client interfaces
- Frontend: canRevokeCompletedRow gate (azure + window open + not yet
  revoked), Revoke button in History action cell, confirm dialog + toast
- All existing mock stores updated with GetPurchaseHistoryByPurchaseID
  and MarkPurchaseRevoked; auth permission-count tests updated to 12

* fix(ci): extract provider dispatch and account check to reduce revoke complexity; regenerate permissions on PR #804

* fix(api/purchases): align revoke admin check with group-only authz (#907)

Rebase onto feat/multicloud-web-frontend brought in #907 (group-membership-
only authorization, no role field on Session). The revoke handler still
gated admin via `session.Role == "admin"`, which no longer compiles since
api.Session has no Role field. Replace with the same two-track pattern the
sibling authorizeSessionCancel / authorizeSessionApprove already use:

  - Stateless admin API key short-circuits via `session.UserID ==
    apiKeyAdminUserID` (no DB row exists to resolve permissions from).
  - Group-based admins fall through to HasPermissionAPI; the {admin, *}
    wildcard in DefaultAdminPermissions matches revoke-any:purchases there.

Drop the dead `Role` field from the revoke test sessions; the admin test
now pins the apiKeyAdminUserID short-circuit, and the existing RevokeAny
test already covers the group-admin path via HasPermissionAPI.

Also fold in the trailing pre-commit fixes that were red on the previous
push:

  - gofmt: realign struct-field padding in TestRevokePurchase_AzureReturnClientError.
  - go mod tidy: promote armreservations from indirect to direct (the
    revoke handler imports it directly).

Free-cancel window enforcement (AzureRevocationWindowDays + windowClosesAt
check) and revoke-call idempotency (early return when record.RevokedAt is
already set) are unchanged.

Refs #290

* fix(api/purchases/revoke): fail-closed on nil account + return error on DB persist failure

Two security fixes from CR findings:

1. checkRevokeOwnAccountAccess: return 403 when CloudAccountID is
   nil/empty instead of allowing the revoke. Without an account
   association, ownership cannot be verified for revoke-own callers.
   Add regression test for this fail-closed contract.

2. revokeAzurePurchase: return error when MarkPurchaseRevoked fails
   after a successful Azure return. The previous log-and-continue
   left the DB unmarked, breaking idempotency on retries. The error
   message notes that the refund was submitted so operators can
   investigate without re-issuing.

* fix(purchases/revoke): populate revocation window at write path so the button works

The Revoke button was shipped but dead: RevocationWindowClosesAt was never
populated when a completed purchase was written, so the frontend gate
canRevokeCompletedRow (which bails on a missing revocation_window_closes_at)
hid the button on every real row.

- Stamp RevocationWindowClosesAt in the real write path
  (purchase.savePurchaseHistory): Azure = Timestamp + 7 days (the free-cancel
  window), nil for AWS/GCP (out of Phase-1 scope), via a new shared
  config.RevocationWindowClosesAtFor helper + config.AzureRevocationWindowDays
  constant that is now the single source of truth for the window length.
- Make the backend window check (revokeAzurePurchase) read
  RevocationWindowClosesAt as the source of truth, falling back to recomputing
  from Timestamp only for legacy rows written before the column was populated.
- Reject an order-only ARM path (empty reservationID) in callAzureReturn
  rather than submitting an empty Return to Azure.
- Tests: backend asserts savePurchaseHistory stamps the window for Azure and
  leaves it nil for AWS/GCP; handler asserts the stamped window drives the
  deny decision and that an empty reservationID is rejected; FE test asserts
  the Revoke button shows for a completed Azure row with a populated
  revocation_window_closes_at and is hidden without it (plus closed-window,
  already-revoked, non-Azure, and anonymous cases).

* docs(auth/revoke): correct revoke-own doc to account-scope reality (#950)

The ActionRevokeOwn doc comment claimed "Own" means created_by_user_id
matches the session user, but checkRevokeOwnAccountAccess actually enforces
ACCOUNT scope (GetAllowedAccountsAPI), because purchase_history rows pre-date
created_by_user_id and have no reliable per-creator attribution.

Fix the doc comments to describe the account-scope behavior as implemented;
the authz model itself is unchanged. Whether revoke-own should instead be
creator-scoped is a product decision tracked in issue #950, noted inline in
both the auth constant doc and the handler check.

* feat(purchases/revoke): Gmail-style pre-fire delay unifies revoke across providers

Adds status=scheduled to purchase_executions so the approval step defers
the cloud SDK call by purchase_delay_hours. A scheduled execution can be
cancelled at $0 via the existing revoke endpoint before the scheduler fires.

Two-tier revoke path:
- status=scheduled: CancelExecutionAtomic (CAS) transitions to cancelled;
  no cloud API call, returns 200 with explicit "no cost incurred" message.
- status=completed (existing): provider SDK call via purchase_history path.

revokePurchase now tries GetExecutionByID first; falls through to the
existing purchase_history lookup when no scheduled execution is found.
authorizeSessionRevokeExecution mirrors authorizeSessionRevoke but uses
CreatedByUserID (not CloudAccountID) for revoke-own scoping.

FireScheduledDelayedPurchases (purchase.Manager) drives the scheduler tick:
queries GetScheduledExecutionsDue, CAS-transitions scheduled->approved,
stamps ApprovedBy="scheduler", calls executeAndFinalize. Registered as
TaskFireScheduledPurchases in the Lambda task dispatcher.

Also folds in three CodeRabbit findings from PR #804 review f9c66c1e:
- Remove unused mockAuth in two AzureCalcRefundClientError/ReturnClientError tests
- Add idempotent audit CHECK constraints to migration 000065
  (revoked_via, support_case_id, revoked_at/revoked_via pair)
- Frontend regression test: legacy blank-status Azure rows stay revocable
- analytics/collector_test.go: hook-backed GetPurchaseHistoryByPurchaseID
  and MarkPurchaseRevoked mocks (no more hardcoded no-ops)

* refactor(api/config): extract helpers to keep revoke+approve+email+scan under gocyclo limit

revokePurchase -> loadAndRevokePurchaseHistory (handler_purchases_revoke.go)
approvePurchase -> approveViaToken (handler_purchases.go)
sendPurchaseScheduledEmail -> buildScheduledEmailData (handler_purchases.go)
scanExecutionRows -> applyNullTimesToExecution (store_postgres.go)

Also applies gofmt alignment fix to internal/analytics/collector_test.go.

* refactor(test/mocks): consolidate MockConfigStore into shared internal/mocks (#804 cleanup)

Three per-package MockConfigStore definitions (internal/api, internal/purchase,
internal/scheduler) were duplicating ~450 LOC each every time a new StoreInterface
method was added. Replace all three with a type alias pointing at internal/mocks.MockConfigStore.

Changes to internal/mocks/stores.go:
- Add Fn-override fields imported from the api and purchase local mocks
  (GetCloudAccountFn, GetPurchasePlanFn, SetPlanAccountsFn, SavePurchaseExecutionFn,
  GetPlanAccountsFn, and 6 others) so callers that used those fields keep working
  without changes.
- Add isExpected guards to recommendation-cache and RI-utilization-cache methods
  and to CancelExecutionAtomic so existing tests that call these without explicit
  On() expectations don't panic (matches the "opt-in" pattern the per-package mocks
  used via hasRecExpectation / hasExpectation).
- Add 8 interface methods that were missing from the shared mock but present in all
  per-package variants: GetExecutionsByStatuses, GetPlannedExecutions,
  GetStaleApprovedExecutions, ListStuckExecutions, GetScheduledExecutionsDue,
  MarkCollectionStarted, ClearCollectionStarted, StampRIExchangeApprovedBy.
- Add compile-time check: var _ config.StoreInterface = (*MockConfigStore)(nil).
- Promote GetGlobalConfig and GetPurchasePlan to return sensible defaults when no
  expectation is registered (matches the api-package behaviour these tests relied on).

Per-package files reduced to a single type alias line each. The scheduler test also
had stray suppression/Tx method stubs added in a later commit; those are removed
because the methods already live on the shared mock.

Intentionally left local (incompatible shape or semantics):
- internal/analytics/collector_test.go: mockConfigStore (lowercase) -- hook-field
  only pattern with no testify embedding; analytics-specific subset; different name.
- internal/server/test_helpers_test.go: mockConfigStoreForHealth -- all-zero-value
  stubs for health check tests; distinct type name; no testify embedding.
- internal/server/handler_ri_exchange_test.go: mockConfigStoreForExchange -- test-
  specific struct overriding a handful of methods.
- internal/server/handler_coverage_test.go: mockConfigStoreForExchange{Complete,
  Fail,Stale} -- per-scenario stubs with distinct type names.

Net LOC: +274 insertions / -1601 deletions (-1327 net across 4 files).

* fix(api/purchases/revoke): use status='scheduled' CAS so pre-fire revoke isn't dead

The pre-fire delay revoke path called CancelExecutionAtomic, whose SQL
guard is `WHERE status IN ('pending','notified')`. A status='scheduled'
row never matches, so the CAS returned zero rows on the happy path --
the handler then surfaced 410 "revocation window has closed" even when
the window was wide open. The user would pay the cloud charge AND see a
"cancelled" attempt in the UI -- the worst possible outcome.

The bug was hidden by mocks defaulting CancelExecutionAtomic to
(true,"cancelled",nil), so every test in the scheduled-revoke suite was
green against the wrong SQL. No pgxmock test exercised the WHERE clause.

Fix: introduce CancelScheduledExecutionAtomic with the correct
`WHERE status = 'scheduled'` guard and switch the handler to it. The two
CAS variants are kept distinct on purpose -- the scheduled-revoke flow
surfaces 410 ("scheduler already fired") on race-loss, while the
pre-purchase cancel flow surfaces 409 ("not pending"). Sharing one method
would conflate the two race outcomes.

Regression test TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS
pins the call to CancelScheduledExecutionAtomic with an Expect and adds
an AssertNotCalled for CancelExecutionAtomic; verified to fail
pre-fix (mock expectation unmet, wrong method called) and pass post-fix.

Touches:
  internal/config/{store_postgres,interfaces}.go -- add method + comments
  internal/api/handler_purchases_revoke{,_test}.go -- switch call site + reg test
  internal/mocks/stores.go -- mock the new method (default happy path)
  internal/server/test_helpers_test.go,
  internal/analytics/collector_test.go -- satisfy StoreInterface

* fix(frontend/history): gate Revoke button on revoke-{any,own} permission

canRevokeCompletedRow only checked getCurrentUser() truthiness, so the
inline Revoke button rendered for every signed-in user regardless of
the revoke-any / revoke-own grant. The backend correctly 403s, but the
UX-vs-RBAC drift is exactly what PR #995 caught for approve / delete
on the same page.

The peer predicates (canCancelPendingRow, canApprovePendingRow,
canRetryFailedRow) already check canAccess; canRevokeCompletedRow now
does too. Verbs match the backend handler one-to-one:

  - admin or revoke-any:purchases -> always allowed
  - revoke-own:purchases -> allowed (account-scope enforced server-side)
  - anything else -> hidden

Adds revoke-own / revoke-any to the closed Action union in
permissions.ts so a future drift becomes a compile error at the
canAccess call site.

Adds a regression test that mocks getCurrentUser with an
effectivePermissions set lacking revoke-* and asserts the button is
hidden; verified to fail without the canAccess gate and pass with it.
Also corrects the existing ADMIN_USER fixture to use the real
ADMINISTRATORS_GROUP_ID GUID -- the prior 'administrators' label was
inert because the new canAccess fallback drives off isAdmin() which
checks GUID membership.

* test(frontend/permissions): update USER_PERMS expected set for revoke-own

PR #804 added 'revoke-own:purchases' to USER_PERMS in
permissions.generated.ts but missed updating the user-role expected
list in __tests__/permissions.test.ts. The test asserts perms.size
matches expected.length, so the missing entry surfaced as
"Expected 11, received 12" after the addition.

This is the same scope as the parent permissions add -- not a separate
permission grant, just the test parity update PR #804 should have
included alongside the original add.

* fix(server): table-drive ParseScheduledEvent + cover scheduled-fire sweep

The fire_scheduled_purchases case pushed ParseScheduledEvent's cyclomatic
complexity to 11, tripping the gocyclo<=10 pre-commit hook and leaving the
PR UNSTABLE. Replace the action switch with a package-level lookup table so
the complexity no longer grows with the task list; adding a task type stays a
one-line change.

Also close the test gap on the Gmail-style pre-fire delay scheduler sweep:
FireScheduledDelayedPurchases / fireOneDue had no unit coverage of their
result accounting or CAS-race classification (only the dispatch wiring was
mocked). Add scheduled_fire_test.go mirroring reaper_test.go:

- no-due-rows and list-error paths
- CAS lost to a concurrent revoke -> RaceLost, not Errored, and no SDK fire
  (the safety property that prevents double-charging a revoked purchase)
- row-vanished (ErrNotFound) -> RaceLost
- hard DB error on the CAS -> Errored (per-row isolation, sweep still succeeds)

Each race/error test fails if the classification regresses (verified by
flipping fireOneDue's return). Add the fire_scheduled_purchases case to the
ParseScheduledEvent table test so the new action is positively asserted.

* fix(migrations): renumber 000068 -> 000070 to deconflict (refs #290)

PR #808 keeps 000068 and PR #847 takes 000069; bump this branch's
purchase_history_revocation migration to 000070 to avoid conflicts
on feat/multicloud-web-frontend.

* fix(scheduler): wire FireScheduledDelayedPurchases tick (CRITICAL: pre-fire delay branch was non-functional)

- Add testify-based FireScheduledDelayedPurchases to scheduler's MockPurchaseManager
  so it records calls and satisfies mock.AssertExpectations.
- Add TestSchedulerManagerInterface_FireScheduledDelayedPurchasesWired: compile-time
  guard that ManagerInterface exposes the method + call-recording smoke test.
- Add TestFireScheduledDelayedPurchases_EndToEnd in scheduled_fire_test.go:
  skipped placeholder (with documented skip reason + issue ref) for the full
  provider-stub e2e once #1005 4-eyes lands.
- Add TestFireScheduledDelayedPurchases_DelayPathNotSilentNoOp: compile-time
  guard that FireScheduledDelayedPurchases exists on Manager.

The server/handler_test.go "fire_scheduled_purchases success" and
"fire_scheduled_purchases propagates error" cases cover the full dispatch
chain (ScheduledTaskType -> handleFireScheduledPurchases -> Purchase.FireScheduledDelayedPurchases).

* fix(api/purchases): CAS-guard scheduleApprovedExecution to prevent silent revoke loss

Replace the blind SavePurchaseExecution write in scheduleApprovedExecution with
a two-step CAS pattern:
  1. TransitionExecutionStatus(pending|notified -> scheduled) -- atomic CAS.
  2. Stamp ScheduledExecutionAt + ApprovedBy on the returned post-CAS row.
  3. SavePurchaseExecution to persist the stamps.

Before this fix a concurrent Cancel that landed between the approve handler's
SELECT and its SavePurchaseExecution would be silently overwritten: the cancelled
row would become status="scheduled" and eventually fire the cloud SDK call the
user explicitly revoked. The CAS ensures the write succeeds only when the row is
still in pending or notified; a concurrent cancel causes ErrExecutionNotInExpectedStatus
which surfaces as a clear error to the caller.

Tests added:
- TestHandler_scheduleApprovedExecution_CASGuardsConcurrentCancel: injects a
  concurrent-cancel error and asserts SavePurchaseExecution is never called.
- TestHandler_scheduleApprovedExecution_HappyPath: normal flow, asserts
  ScheduledExecutionAt is stamped on the transitioned row.

* feat(purchases/revoke): two-step quote-then-confirm + persist refund amount for audit

Addresses adversarial-review Finding #4:

- Add GET /api/purchases/revoke/calculate/{id} endpoint (calculateAzureRevoke)
  that calls CalculateRefund and returns the quoted refund amount and currency;
  no state mutation, used by the frontend confirmation modal.
- Thread expectedRefundAmount through dispatchProviderRevoke -> revokeAzurePurchase
  -> callAzureReturn; on POST /revoke the client sends the amount it consented
  to and callAzureReturn re-runs CalculateRefund, rejecting with 422 when the
  new quote diverges by more than revokeQuoteEpsilon (0.01) to close the TOCTOU
  window between user confirmation and actual Return call.
- Persist calc_refund_amount / calc_refund_currency via MarkPurchaseRevoked so the
  audit row captures the quoted values even if the actual refund differs later.
- Migration 000071 adds the two new nullable columns and a consistency CHECK
  constraint (currency must be non-empty when amount is present).
- New tests: TestCallAzureReturn_TOCTOUDivergenceRejectedWith422,
  TestCallAzureReturn_TOCTOUWithinEpsilonSucceeds,
  TestCallAzureReturn_AuditRowPopulatedWithQuote.

* fix(purchases/revoke): partial-success reconciliation; never retry a refund that already succeeded

Addresses adversarial-review Finding #6:

- Migration 000072 adds revocation_in_flight BOOLEAN NOT NULL DEFAULT false to
  purchase_history plus a partial index on rows where the flag is true.
- callAzureReturn flips revocation_in_flight=true via
  FlipPurchaseRevocationInFlight immediately before the Azure Return API call so
  the row is visible to the finalize sweep if the subsequent MarkPurchaseRevoked
  DB write fails.
- MarkPurchaseRevoked is retried up to 3 times with 1s/3s/9s backoff after
  Azure Return succeeds; if all retries fail, the endpoint returns a
  revokeReconcilePendingResult (HTTP 207 body with code=RECONCILE_PENDING,
  azure_returned=true) so the frontend shows a non-retryable toast rather than
  prompting the user to retry a refund that Azure already issued.
- loadAndRevokePurchaseHistory detects a row with revocation_in_flight=true and
  revoked_at=nil and immediately returns 207 RECONCILE_PENDING to prevent any
  duplicate Azure Return call on retry.
- purchase.Manager.FinalizeInFlightRevocations sweeps rows matching
  GetPurchaseHistoryInFlight and retries MarkPurchaseRevoked with 2s/6s backoff
  per row; the finalize_revocations scheduled task wires this sweep into the
  Lambda event handler.
- New tests: TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries,
  TestLoadAndRevokePurchaseHistory_RevocationInFlightReturns207,
  finalize_revocations success and error dispatch tests.

* fix(purchases/revoke): typed Azure error classification (no more substring match on err.Error())

Addresses adversarial-review Finding #7:

Replace the string-based isAzureClientError implementation with typed error
inspection using errors.As(err, &*azcore.ResponseError). The substring-match
approach had two failure modes:
- False positives: any error whose .Error() string contains "400" (e.g. a
  network timeout "timeout after 400ms") would be misclassified as a
  client error, hiding transient infra problems from the operator.
- False negatives: Azure refund-policy errors with HTTP codes not in the
  literal set (e.g. 403, 405) would be escalated as 500.

The typed approach classifies exactly the HTTP status codes Azure uses for
policy violations and bad requests (400, 403, 404, 405, 409, 422); all other
errors (transport errors, 5xx, plain errors) correctly classify as server-side.

Updated TestRevokePurchase_AzureCalcRefundClientError to inject a real
*azcore.ResponseError{StatusCode: 400} instead of errors.New("400: ...").

New tests: TestIsAzureClientError_SubstringFalsePositive,
TestIsAzureClientError_TypedResponseError (covers 4xx client + 5xx server).

* fix(purchases/revoke): 1h safety margin on local window + clean 422 on Azure window-edge rejection

Addresses adversarial-review Finding #3:

Add azureRefundSafetyMargin = 1h so the in-app revoke button disappears and
revoke requests are rejected 1h before Azure's hard 7-day deadline. This
eliminates the tail of RefundPolicyViolated failures caused by clock skew
between CUDly's clock and Azure's at the window boundary.

The safety margin is applied only in the local pre-flight check. The value
stored in purchase_history.revocation_window_closes_at remains the unmodified
Azure deadline so operators can see the true expiry.

Additionally, detect RefundPolicyViolated errors from the Return API via the
new isAzureWindowEdgeError helper (typed errors.As on *azcore.ResponseError,
checking ErrorCode == "RefundPolicyViolated") and map them to a clean 422 with
"window has closed" message rather than the generic "Azure refund rejected" 400
path. This handles the race where our safety-margin check passes but Azure's
clock disagrees mid-flight.

New tests:
- TestRevokePurchase_AzureWithinSafetyMarginRejected: purchase 6d23h30m ago
  (30min before edge) rejected locally despite Azure deadline not yet passed.
- TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed: purchase 6d22h30m
  ago (90min before edge) accepted.
- TestIsAzureWindowEdgeError: table test covering RefundPolicyViolated, other
  error codes, nil, and plain-error false-positive.
- TestCallAzureReturn_RefundPolicyViolatedReturns422WindowEdge: Return API
  returning RefundPolicyViolated surfaces as 422, not 400.

* fix(migrations): allow support-case revoke to record in-flight state (case filed, awaiting AWS)

Addresses adversarial-review Finding #5:

The original purchase_history_revoked_pair_chk required revoked_at and
revoked_via to be set or unset together. This is too strict for the AWS
support-case revocation path (issue #291 wave-2): when a case is filed,
revoked_via='support-case' is recorded immediately for the audit trail, but
revoked_at stays NULL until AWS confirms the refund. The pair check fires
as a constraint violation in that in-flight state.

Migration 000073 drops the pair check and simultaneously tightens the
support-case companion check:

  Old: CHECK (support_case_id IS NULL OR revoked_via = 'support-case')
       (prevents support_case_id on non-support-case rows only)
  New: CHECK (revoked_via != 'support-case' OR support_case_id IS NOT NULL)
       (requires support_case_id whenever revoked_via = 'support-case')

The meaningful invariant (no dangling revoked_at without a known provider
path) is preserved by the existing purchase_history_revoked_via_chk which
constrains revoked_via to ('direct-api', 'support-case').

* test(purchases/revoke): DST-crossing window math + 4-eyes approval placeholder

Addresses adversarial-review Finding #9:

TestRevocationWindowClosesAtFor_DSTCrossing: verifies that AddDate(0,0,7)
(calendar arithmetic) produces the correct 7-day window across a DST
transition. The test uses the 2024 US spring-forward on March 10 (02:00 EST ->
03:00 EDT): a purchase at 01:30 must close at 01:30 seven days later, not at
00:30 as a naive Add(168*time.Hour) would produce. This pins the correct
behaviour and documents why a fixed-duration approach would be wrong.

TestRevokePurchase_FourEyesApproval: skipped placeholder for the revoke
4-eyes approval gate tracked in issue #1005. The skip keeps the suite green
while the feature is in flight and serves as a reminder to fill in the
implementation when #1005 lands.

* fix(api/purchases): map scheduleApprovedExecution CAS race to 409 (not 500)

When a concurrent Cancel flips the execution away from schedulable status
between the approve flow check and the CAS write, approveWithDelay now returns
409 instead of 500. ErrExecutionNotInExpectedStatus from TransitionExecutionStatus
is the discriminator; any other error continues to surface as 500.

* fix(api/purchases/revoke): drop pre-check, distinguish GetExecutionByID errors

Two related fixes on the same line (revokePurchase GetExecutionByID branch):

Finding B: Remove the racy status=="scheduled" pre-check. The old code read
the status from the DB and then only dispatched to revokeScheduledExecution
when it was "scheduled", but a concurrent writer could flip the status between
the read and the CancelScheduledExecutionAtomic call. Drop the pre-check and
let CancelScheduledExecutionAtomic's WHERE status='scheduled' CAS decide; a
lost CAS returns 410 as expected.

Finding C: Distinguish a genuine DB error (non-nil execErr) from a missing row
(nil, nil). Before the fix, any non-nil execErr was folded into the "execErr ==
nil && ..." condition and silently swallowed, falling through to the history
lookup. Now a non-nil execErr surfaces immediately as a wrapped error (500).

* fix(api/purchases/revoke): clear revocation_in_flight on Azure error paths (transient retryable)

When the Azure Return call fails (window-edge, client-error, or transient), the
revocation_in_flight flag was left stuck at true. The finalize_revocations sweep
would then treat the row as "Azure succeeded, DB write pending" and retry
MarkPurchaseRevoked unnecessarily, potentially marking a purchase as revoked
when it was never actually returned.

Fix: call ClearRevocationInFlight (new store method) on all Azure Return error
paths so the row reverts to its original status. On success the flag stays true
for the sweep to handle (existing wave-1 behaviour unchanged).

Adds ClearRevocationInFlight to StoreInterface, PostgresStore, and MockConfigStore.

* fix(frontend/history): expose Revoke button for status='scheduled' rows + regression test

Three related changes to surface the Revoke button on Gmail-style pre-fire
delayed executions (status='scheduled') in the History UI:

1. Add "scheduled" to historyExecutionStatuses so these rows appear in the
   /api/history response at all (they were previously invisible).

2. Populate RevocationWindowClosesAt from ScheduledExecutionAt for scheduled
   rows in annotateHistoryRowByStatus so the frontend window check
   (revocation_window_closes_at in the future) works without a new field.

3. Update canRevokeCompletedRow to accept status==="scheduled" in addition
   to "completed" and "" (legacy blank), so the Revoke button renders.

Adds regression test: a scheduled Azure row with a future revocation window
must show the Revoke button (Findings E + G, second-wave CR).

* fix(test/purchases/revoke): fix AssertNotCalled placement + isolate parallel test backoff state

Finding F-1: Move AssertNotCalled(t, "CancelExecutionAtomic") to after the
handler call in TestRevokePurchase_ScheduledExecution_BugReg_HappyPathCAS.
The assertion was placed before h.revokePurchase(), where it trivially passes
regardless of what the handler does.

Finding F-2: Drop t.Parallel() from TestCallAzureReturn_MarkPurchaseRevokedFailAllRetries.
The test mutates the package-global revokeMarkRetryBackoffs slice; running it
in parallel with other tests that read the same variable is a data race. The
test already uses t.Cleanup to restore the original value, which is sufficient
when run sequentially.

* fix(test/history-revoke): add missing mock entries for escapeHtmlAttr and getAmortizeUpfront

The state mock lacked getAmortizeUpfront/setAmortizeUpfront/subscribeAmortizeUpfront
and the utils mock lacked escapeHtmlAttr/amortizedMonthly. Both are called inside
renderHistoryList; the missing entries caused a TypeError that loadHistory's try/catch
swallowed, silently producing an empty history-list and hiding the Revoke button for
all three "shows Revoke" cases.

* fix(purchases/revoke): let the CAS decide scheduled cancellability (#290)

Remove the early window-expiry 410 in revokeScheduledExecution. A row
still in status="scheduled" has not been transitioned by the scheduler,
so the cloud SDK call has not fired regardless of how far
scheduled_execution_at is in the past (scheduler lag / backpressure).
Returning 410 purely on a past timestamp broke free-cancel during lag
even though CancelScheduledExecutionAtomic could still cancel the row
before any cloud call. Let the CAS be the sole arbiter: it returns
cancelled=false (410) only when the row has actually moved out of
"scheduled".

Updates the former WindowExpired test to assert the new contract (a
past-timestamp scheduled row is cancelled for free via the CAS), and
refreshes two stale "window-check" comments. Closes the last open
CodeRabbit thread on PR #804.

* fix(email/revoke): require recipient for scheduled-delay email; tidy tests (#290)

Address the second CodeRabbit review pass on PR #804:

- Security: SendPurchaseScheduledNotification (SES Sender) no longer
  falls back to the broadcast SendNotification path when RecipientEmail
  is empty. That email embeds a live, execution-scoped revoke link, so
  broadcasting it leaked an action link to every alert subscriber and
  broke the ownership/RBAC model around revocation. It now returns
  ErrNoRecipient, matching SendScheduledPurchaseNotification and the SMTP
  sender. Adds a regression test asserting ErrNoRecipient on empty
  recipient.
- Test: rename TestRevokePurchase_AzureJustOutsideSafetyMarginAllowed ->
  TestCallAzureReturn_JustOutsideSafetyMargin and correct its docstring;
  it drives callAzureReturn directly and never exercised the local
  1h safety-margin gate (which lives in dispatchProviderRevoke). The
  reject side of that gate stays covered end-to-end via
  TestRevokePurchase_AzureWithinSafetyMarginRejected.
- Build: implement the ClearRevocationInFlight StoreInterface method on
  the standalone analytics and server test mocks (mockConfigStore,
  mockConfigStoreForExchange, mockConfigStoreForHealth) so those test
  packages compile after the interface gained the method.

* refactor(purchases): reduce cyclomatic complexity below the pre-commit gate (#290)

The gocyclo pre-commit hook (threshold 10) failed CI on four functions
the #290 feature work grew past the limit. Split each into focused
helpers with no behavior change:

- calculateAzureRevoke (21): extract validateAzureRevokeRequest (auth +
  load + authorize + window/ID validation, itself split into
  azureRevokeWindowAndIDs) and extractAzureRefundQuote.
- callAzureReturn (21): extract azureCalculateRefund (CalculateRefund +
  parse), handleAzureReturnError (clear in-flight + status mapping), and
  persistAzureRevocation (MarkPurchaseRevoked retry + 207 result).
- annotateHistoryRowByStatus (12): move the in-flight / audit-gap cases
  into annotateInFlightOrAuditGapRow.
- dispatchTask (11): switch to a map-based dispatch.

gocyclo now reports nothing over 10; full internal test suite green
(except the pre-existing CSRF flake that also fails on base).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

impact/many Affects most users priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/security Security finding urgency/this-sprint Within the current sprint

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant