Skip to content

feat(history): inline Cancel button + cancel-any/own RBAC verbs#145

Merged
cristim merged 4 commits intofeat/multicloud-web-frontendfrom
feat/history-pending-cancel
Apr 27, 2026
Merged

feat(history): inline Cancel button + cancel-any/own RBAC verbs#145
cristim merged 4 commits intofeat/multicloud-web-frontendfrom
feat/history-pending-cancel

Conversation

@cristim
Copy link
Copy Markdown
Member

@cristim cristim commented Apr 27, 2026

Summary

Closes #46. Adds an inline Cancel button on pending Purchase History rows so a user can abort an in-flight approval from the dashboard instead of digging through the inbox for the email-token link. The token-only escape hatch stays unchanged.

RBAC decision (Option C)

Two new permission verbs in internal/auth/types.go:

  • cancel-any + purchases — admin-only by default (covered by RoleAdmin's implicit {admin, *}). Lets an admin cancel ANY user's pending execution. The verb exists as a distinct constant so a future non-admin operator role can be granted broad cancel rights without escalating to admin.
  • cancel-own + purchases — added to DefaultUserPermissions(). Lets an authenticated user cancel pending executions whose created_by_user_id matches their session UserID.

Authorization matrix (session-authed branch — ?token= empty)

caller own pending row someone else's row legacy NULL-creator row
admin allow allow allow
user (cancel-own) allow 403 403
user (no verb) 403 403 403

Legacy rows with NULL creator stay reachable by admins (cancel-any) or via the email token already in the user's inbox — we never lock anyone out.

Pre-flight findings

  • Permission model: internal/auth/types.go exposes Action+Resource tuples. Admin gets a synthetic {admin, *} from DefaultAdminPermissions(); the user role gets six entries from DefaultUserPermissions(). The issue's cancel:any_execution / cancel:own_executions map cleanly onto cancel-any/cancel-own × purchases.
  • Creator attribution: purchase_executions had no created_by_user_id column. Migration 000041 adds it as nullable UUID REFERENCES users(id) ON DELETE SET NULL. NULL is the legitimate state for scheduler-driven executions and any pre-migration row. The cancel handler treats NULL as "not the current user".
  • Cancel handler shape: cancelPurchase was AuthPublic + token-gated (?token=…). Extended in-place to a two-mode dispatch: token != "" runs the legacy email-token flow unchanged; token == "" enters the new session-authed branch which enforces the matrix above and stamps session.Email onto CancelledBy so History renders "cancelled by <email>".

Backend changes

  • Migration 000041_purchase_executions_created_by_user_id (up + down).
  • New permission constants ActionCancelAny / ActionCancelOwn and inclusion in DefaultUserPermissions().
  • PurchaseExecution.CreatedByUserID *string + persistence in SavePurchaseExecutionTx (INSERT-only — never overwritten by ON CONFLICT).
  • executePurchase stamps the session UserID onto new rows when the UserID is a real UUID (resolveCreatorUserID helper).
  • cancelPurchase now dispatches token vs session paths; new cancelPurchaseViaSession, authorizeSessionCancel, requireSession.
  • History API exposes created_by_user_id on synthesised pending rows so the frontend can render the button conditionally.

Frontend changes

  • HistoryPurchase.created_by_user_id?: string.
  • canCancelPendingRow() in history.ts mirrors the backend matrix as a UX gate (backend remains authoritative).
  • Inline Cancel button in the Plan column of pending/notified rows. Click → confirmDialogapi.cancelPurchase(id)loadHistory() → success/error toast.

Test plan

  • Backend: 7 new tests in handler_purchases_test.go covering all four matrix cells, the cancellable-state 409, the legacy NULL-creator rejection for non-admins, the missing-session 401, and the cancel-any-as-non-admin path. Existing TestHandler_cancelPurchase happy-path test still covers the legacy email-token flow.
  • Frontend: 7 new tests in history-cancel-button.test.ts covering button visibility (admin, user, no-user, non-pending), declined confirm, accepted confirm + reload + toast, and the API-failure-toast-and-re-enable flow.
  • go build ./..., go vet ./..., go test ./... clean.
  • npx tsc --noEmit, npm run build, full Jest suite (1303 tests) clean.
  • gocyclo / pre-commit hooks clean.
  • Smoke test on the deployed Lambda URL after CI runs the deploy.

Out of scope

Summary by CodeRabbit

  • New Features

    • Inline Cancel button on History for pending (including "notified") purchases: admins can cancel any, regular users can cancel their own, anonymous users see none.
    • History rows now include creator attribution to enable owner-based actions.
  • Bug Fixes

    • Confirmation dialog, button disable/enable behavior, success/error notifications, and list refresh behavior refined (success toast retained even if refresh fails).
  • Tests

    • Comprehensive client- and server-side tests added/updated to cover session-authenticated cancellation and RBAC scenarios.
  • Chores

    • Database migration added to record execution creator IDs.

Adds a session-authed Cancel button to pending Purchase History rows so
users can abort an in-flight approval from the dashboard instead of
hunting through their inbox for the email-token link. The token-only
escape hatch stays unchanged.

Decision (issue #46, Option C):
  - cancel-any:purchases — granted via RoleAdmin/ResourceAll. Lets an
    admin cancel ANY user's pending execution. The new constant exists
    so a future non-admin operator role can be granted broad cancel
    rights without escalating to admin.
  - cancel-own:purchases — added to DefaultUserPermissions(). Lets an
    authenticated user cancel pending executions whose creator UUID
    matches their own session UserID.

RBAC matrix (session-authed branch — token == ""):

  | role / verb         | own row | someone else's | legacy NULL row |
  |---------------------|---------|----------------|-----------------|
  | admin               |  allow  |     allow      |      allow      |
  | user (cancel-own)   |  allow  |     403        |      403        |
  | user (no verb)      |   403   |     403        |      403        |

Pre-flight findings:
  - Permissions are {action, resource} tuples in internal/auth/types.go.
    Admin gets a synthetic {admin, *} via DefaultAdminPermissions(); the
    user role gets six entries from DefaultUserPermissions(). The
    issue's "cancel:any_execution" / "cancel:own_executions" map cleanly
    onto Action="cancel-any"/"cancel-own" + Resource=purchases.
  - purchase_executions had NO created_by_user_id column. Migration 41
    adds it as a nullable UUID FK to users(id) ON DELETE SET NULL. NULL
    is the legitimate state for scheduler-driven executions and any row
    written before the migration. The cancel handler treats NULL as
    "not the current user", so legacy rows fall through to cancel-any
    (admins) or the existing email token in the inbox; we never lock a
    user out of a pending approval they could already act on.
  - executePurchase now stamps the session UserID onto the row when
    it's a real UUID (not the "admin-api-key" sentinel from the API-key
    path), populating future rows for the cancel-own check.
  - cancelPurchase keeps a two-mode dispatch: token != "" runs the
    legacy email-token flow unchanged; token == "" enters the new
    session-authed branch which enforces the matrix above and stamps
    session.Email onto CancelledBy for the audit trail.

Frontend:
  - HistoryPurchase carries created_by_user_id; the Cancel button
    renders only when canCancelPendingRow() returns true (mirrors the
    backend RBAC matrix — the backend remains authoritative).
  - Click → confirmDialog → POST /api/purchases/cancel/:id (no token)
    → loadHistory(). Errors surface via toast and re-enable the button.

Testing:
  - 7 backend tests cover all four matrix cells, the cancellable-state
    409, the legacy NULL-creator rejection for non-admins, the
    missing-session 401, and the cancel-any allow path for non-admin
    roles.
  - 7 frontend tests cover button visibility (admin vs user vs no
    user vs non-pending), declined confirm, accepted confirm, and the
    error-surface-and-re-enable flow.

Out of scope:
  - Retry button (issue #47).
  - Cancel for non-pending statuses (e.g. undo a cancellation).

Closes #46
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Warning

Rate limit exceeded

@cristim has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 0 minutes and 48 seconds before requesting another review.

To keep reviews running without waiting, you can enable usage-based add-on for your organization. This allows additional reviews beyond the hourly cap. Account admins can enable it under billing.

⌛ How to resolve this issue?

After the wait time has elapsed, 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 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 configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4221c7bf-0824-47e9-919f-753000560948

📥 Commits

Reviewing files that changed from the base of the PR and between 71d1cf7 and f0666bd.

📒 Files selected for processing (1)
  • internal/api/handler_purchases_test.go
📝 Walkthrough

Walkthrough

Adds session-authenticated cancellation for pending/notified purchase executions, exposes a row-level Cancel button in History, records execution creators in the DB/models, introduces RBAC verbs cancel-own/cancel-any, and adds frontend and backend tests to validate visibility, authorization, and error/success flows.

Changes

Cohort / File(s) Summary
Frontend: History UI & tests
frontend/src/history.ts, frontend/src/__tests__/history-cancel-button.test.ts
Render inline Cancel for pending/notified rows when canCancelPendingRow allows (admin always; non-admin only if created_by_user_id matches current user). Confirm dialog → api.cancelPurchase (session path), disable button during request, show toasts, reload history on success. Tests cover role/status visibility, confirm/no-confirm flows, success/error handling, and toast behavior when reload fails.
Frontend types
frontend/src/types.ts
Adds optional created_by_user_id?: string to HistoryPurchase for row ownership checks.
API: cancel path & execute
internal/api/handler_purchases.go, internal/api/handler_purchases_test.go
cancelPurchase gains session-authenticated branch when token=="": validates session bearer, enforces RBAC via cancel-own/cancel-any, restricts to pending/notified, atomically sets status=cancelled and clears suppressions, stamps CancelledBy. executePurchase records CreatedByUserID. Tests cover admin/any/own/denials, terminal-state rejections, missing Authorization header, and SavePurchaseExecution assertions.
History projection
internal/api/handler_history.go
executionToHistoryRow projects execution CreatedByUserID into history response for non-completed rows.
Auth types & tests
internal/auth/types.go, internal/auth/types_test.go, internal/auth/service_test.go, internal/auth/service_group_test.go
Adds ActionCancelOwn and ActionCancelAny verbs; DefaultUserPermissions() grants cancel-own:purchases to non-admin authenticated users. Tests updated to expect +1 permission and include cancel-own.
Config store / SQL / migrations
internal/config/store_postgres.go, internal/config/store_postgres_pgxmock_test.go, internal/database/postgres/migrations/000041_purchase_executions_created_by_user_id.up.sql, internal/database/postgres/migrations/000041_purchase_executions_created_by_user_id.down.sql
Adds nullable purchase_executions.created_by_user_id (UUID fk -> users.id ON DELETE SET NULL). Insert writes created_by_user_id (INSERT-only), upsert excludes it. Execution read queries updated to select/scan new column; pgxmock tests updated.
Data models
internal/config/types.go
PurchaseExecution gains CreatedByUserID *string; PurchaseHistoryRecord gains transient CreatedByUserID string (excluded from DB persistence) for API synthesis.
Coverage / handler tests
internal/api/coverage_extras_test.go
Replaces empty-token short-circuit expectation with a test that proceeds into session flow and asserts a downstream "failed to get execution" when lookup fails.
Documentation
known_issues/30_history_pending_cancel_ui.md
Deleted prior doc describing the missing UI cancel path now implemented.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Frontend as Frontend UI
    participant API as Purchases API
    participant Auth as Auth Service
    participant DB as Database

    User->>Frontend: Click Cancel on pending/notified row
    Frontend->>Frontend: Show confirmDialog
    User->>Frontend: Confirm
    Frontend->>API: POST /api/purchases/cancel (session token, execution_id)
    API->>Auth: Validate session & RBAC (cancel-own / cancel-any)
    Auth-->>API: Allow / Deny
    alt Denied
        API-->>Frontend: 403/401
        Frontend->>User: show error toast
    else Allowed
        API->>DB: GetExecutionByID (check status, CreatedByUserID)
        alt Status not cancellable
            API-->>Frontend: 400/409
            Frontend->>User: show error toast
        else Cancellable
            API->>DB: Transaction: set status=cancelled, clear suppressions, set CancelledBy
            DB-->>API: OK
            API-->>Frontend: 200 OK
            Frontend->>Frontend: show success toast
            Frontend->>API: GET /api/purchases/history (reload)
            API->>DB: Query history (includes created_by_user_id)
            DB-->>API: History
            API-->>Frontend: Updated history
            Frontend->>User: re-render list
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nudged a button, soft and sly,

Pending rows no longer cry.
Session keys and rights aligned,
Creator's cancel now defined.
Toasts sing true — the history sighs!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.67% 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 clearly describes the two main features: an inline Cancel button for history UI and the addition of new RBAC permission verbs (cancel-any/cancel-own).
Linked Issues check ✅ Passed The PR implements all objectives from issue #46: session-authenticated cancel path, pending/notified row display control, legacy token flow preservation, new RBAC verbs (cancel-own/cancel-any), and audit trails with CancelledBy stamping.
Out of Scope Changes check ✅ Passed All changes directly support the cancel button feature and RBAC implementation. Database schema, API handlers, auth types, frontend UI, and tests are cohesive and scoped to issue #46 objectives.

✏️ 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 feat/history-pending-cancel

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

@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ Actions performed

Review triggered.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
frontend/src/types.ts (1)

171-176: Clarify the field contract.

The backend now carries created_by_user_id on all synthetic execution history rows, not just pending/notified ones. Consider widening the comment so the frontend contract stays accurate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/types.ts` around lines 171 - 176, Update the comment for the
created_by_user_id field to reflect the new backend contract: mention that
created_by_user_id (on the type created_by_user_id?: string) is now populated on
all synthetic execution history rows synthesized from purchase_executions (not
only pending/notified rows), and keep the note about how the History UI uses it
to decide visibility of the inline Cancel button for non-admins (i.e.,
non-admins only see Cancel on rows they created) so the frontend contract
remains accurate.
internal/api/handler_purchases_test.go (1)

1168-1175: Assert the CancelledBy audit stamp in the happy-path tests.

These cases currently prove the status transition, but not the new audit behavior. If cancelPurchaseViaSession stops persisting CancelledBy = session.Email, this suite still passes because SavePurchaseExecution is only stubbed, not inspected.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handler_purchases_test.go` around lines 1168 - 1175, Update the
happy-path test to assert the audit stamp by capturing the execution passed to
SavePurchaseExecution: when building the handler with buildSessionCancelHandler,
change the mockConfig.SavePurchaseExecution stub to use mock.Run (or an argument
capture) to inspect the saved execution and assert its CancelledBy equals
session.Email (or set expectation that SavePurchaseExecution is called with an
execution whose CancelledBy == session.Email); keep the existing
TransitionExecutionStatus and result assertions but add this capture/assertion
against the saved execution for cancelExecID in the cancelPurchase call path.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@frontend/src/history.ts`:
- Around line 291-309: canCancelPendingRow currently only allows admins or the
original creator, but the backend authorizeSessionCancel also permits non-admin
users with the "cancel-any:purchases" permission; update canCancelPendingRow to
mirror that by checking the current user's permissions (from getCurrentUser())
for "cancel-any:purchases" and returning true if present (in addition to the
existing admin check), while preserving the existing pending/notified and
created_by_user_id creator logic; reference canCancelPendingRow and
authorizeSessionCancel when making the change.
- Around line 416-439: The try block currently wraps both api.cancelPurchase(id)
and loadHistory(), causing a failed refresh to be treated as a cancel failure;
refactor the click handler attached in
container.querySelectorAll<HTMLButtonElement>('.history-cancel-btn[data-cancel-id]')
so you only await api.cancelPurchase(id) inside the try and, on success,
immediately call showToast({ message: 'Purchase cancelled', ... }) and set
btn.disabled = true (or leave it disabled), then run loadHistory() in a separate
try/catch so any refresh errors are handled separately (e.g. show a different
toast or console.error) and do NOT re-enable the button in the refresh error
path; keep references to api.cancelPurchase, loadHistory, showToast, and
btn.disabled to locate and update the relevant logic.

In `@internal/auth/types.go`:
- Around line 278-293: The comment incorrectly states that cancel-own:purchases
is granted to "every authenticated user role by default"; update the comment to
accurately reflect that ActionCancelOwn (and ActionCancelAny) are defined here
but cancel-own:purchases is only added by DefaultUserPermissions(), while
DefaultReadOnlyPermissions() continues to return view-only permissions, so not
all authenticated roles get cancel-own by default — mention
DefaultUserPermissions and DefaultReadOnlyPermissions and clarify the actual
scope (user roles via DefaultUserPermissions, admins via
ActionCancelAny/ActionAdmin or ResourceAll).

---

Nitpick comments:
In `@frontend/src/types.ts`:
- Around line 171-176: Update the comment for the created_by_user_id field to
reflect the new backend contract: mention that created_by_user_id (on the type
created_by_user_id?: string) is now populated on all synthetic execution history
rows synthesized from purchase_executions (not only pending/notified rows), and
keep the note about how the History UI uses it to decide visibility of the
inline Cancel button for non-admins (i.e., non-admins only see Cancel on rows
they created) so the frontend contract remains accurate.

In `@internal/api/handler_purchases_test.go`:
- Around line 1168-1175: Update the happy-path test to assert the audit stamp by
capturing the execution passed to SavePurchaseExecution: when building the
handler with buildSessionCancelHandler, change the
mockConfig.SavePurchaseExecution stub to use mock.Run (or an argument capture)
to inspect the saved execution and assert its CancelledBy equals session.Email
(or set expectation that SavePurchaseExecution is called with an execution whose
CancelledBy == session.Email); keep the existing TransitionExecutionStatus and
result assertions but add this capture/assertion against the saved execution for
cancelExecID in the cancelPurchase call path.
🪄 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: 63988262-8812-41b3-a724-5dad9a5443fd

📥 Commits

Reviewing files that changed from the base of the PR and between 71f8ab8 and 9a2f088.

📒 Files selected for processing (17)
  • frontend/src/__tests__/history-cancel-button.test.ts
  • frontend/src/history.ts
  • frontend/src/types.ts
  • internal/api/coverage_extras_test.go
  • internal/api/handler_history.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/auth/service_group_test.go
  • internal/auth/service_test.go
  • internal/auth/types.go
  • internal/auth/types_test.go
  • internal/config/store_postgres.go
  • internal/config/store_postgres_pgxmock_test.go
  • internal/config/types.go
  • internal/database/postgres/migrations/000041_purchase_executions_created_by_user_id.down.sql
  • internal/database/postgres/migrations/000041_purchase_executions_created_by_user_id.up.sql
  • known_issues/30_history_pending_cancel_ui.md
💤 Files with no reviewable changes (1)
  • known_issues/30_history_pending_cancel_ui.md

Comment thread frontend/src/history.ts
Comment on lines +291 to +309
// canCancelPendingRow returns true when the current session is permitted
// to cancel the given pending/notified history row via the session-authed
// Cancel button (issue #46). Mirror of the backend RBAC matrix in
// internal/api/handler_purchases.go::authorizeSessionCancel — keep both
// sides in sync. The backend remains authoritative; this helper is a UX
// gate (don't show buttons users can't use) and never the security
// boundary.
function canCancelPendingRow(p: HistoryPurchase): boolean {
const status = (p.status || '').toLowerCase();
if (status !== 'pending' && status !== 'notified') return false;
const user = getCurrentUser();
if (!user) return false;
if (user.role === 'admin') return true;
// Non-admin: only the original creator. Legacy rows with no
// created_by_user_id can't be cancelled via this UI; the email-token
// path remains the escape hatch.
if (!p.created_by_user_id) return false;
return p.created_by_user_id === user.id;
}
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Apr 27, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

canCancelPendingRow is not actually mirroring backend auth.

authorizeSessionCancel also permits non-admin users who hold cancel-any:purchases, but this helper only allows admins or creators. Any operator/user role granted cancel-any will be allowed by the API and still never see the inline action here.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/history.ts` around lines 291 - 309, canCancelPendingRow
currently only allows admins or the original creator, but the backend
authorizeSessionCancel also permits non-admin users with the
"cancel-any:purchases" permission; update canCancelPendingRow to mirror that by
checking the current user's permissions (from getCurrentUser()) for
"cancel-any:purchases" and returning true if present (in addition to the
existing admin check), while preserving the existing pending/notified and
created_by_user_id creator logic; reference canCancelPendingRow and
authorizeSessionCancel when making the change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Verified against current code. The intentional UX trade-off is documented inline at lines 303-310 of frontend/src/history.ts: surfacing the inline Cancel for non-admin custom-operator roles holding cancel-any:purchases would require fetching /me/permissions and extending the frontend User type to carry permissions. No default role grants cancel-any (it's reserved for future operator roles), so the practical impact today is zero. The backend authorizeSessionCancel remains the security boundary; if a custom role with cancel-any is added in the future, the inline comment explicitly flags broadening this helper as the follow-up. Skipping here to keep the PR scoped to RBAC + the inline button.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

@cristim, understood — the rationale is well-reasoned and the inline comment (the "Caveat" block in canCancelPendingRow) already documents the divergence clearly for future maintainers, including the explicit pointer to extend User to carry permissions if an operator role with cancel-any:purchases is ever introduced. Happy to leave it as-is.


✏️ Learnings added
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 145
File: frontend/src/history.ts:291-322
Timestamp: 2026-04-27T21:44:58.439Z
Learning: In `frontend/src/history.ts`, `canCancelPendingRow` intentionally does NOT mirror the backend `authorizeSessionCancel` for non-admin users holding `cancel-any:purchases`. This is a deliberate UX trade-off: no default role grants `cancel-any` (it is reserved for future operator roles), so the practical impact is zero today. Adding the check would require a `/me/permissions` round-trip and extending the `User` type to carry permissions — deemed out of scope. The backend `authorizeSessionCancel` in `internal/api/handler_purchases.go` remains the security boundary. If an operator role with `cancel-any:purchases` is introduced in the future, `canCancelPendingRow` and the `User` type should be extended accordingly.

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.

Comment thread frontend/src/history.ts
Comment thread internal/api/handler_purchases.go
Comment thread internal/auth/types.go
Four review findings, all addressed in the same changeset:

1. Major — `cancelPurchaseViaSession` skipped suppression cleanup.
   The token-flow `purchase.Manager.CancelExecution` deletes
   `purchase_suppressions` rows in the same tx as the status flip so
   cancelled executions stop hiding capacity from recommendations.
   The new session-flow only ran `TransitionExecutionStatus`, leaving
   suppressions in place until the grace window expired. Refactor to
   `WithTx { SavePurchaseExecutionTx; DeleteSuppressionsByExecutionTx }`
   matching the email-token path. Status guard moves into
   `SavePurchaseExecutionTx`'s natural ON CONFLICT semantics +
   the explicit pending/notified state check before the tx.

2. Major — frontend cancel/refresh shared one try/catch. A successful
   POST followed by a failed `loadHistory()` rendered a misleading
   "Failed to cancel" toast even though the purchase WAS cancelled.
   Split into two try blocks: cancel result drives the success/error
   toast and button re-enable; reload failure logs but doesn't
   override the success surface. Adds a regression test
   (`successful cancel + failed reload still surfaces success toast`).

3. Major — `canCancelPendingRow` didn't mirror `cancel-any` for non-
   admin operator roles. The frontend can't reach the user's
   permission list today (User type doesn't carry it), so the
   heuristic stays admin-or-creator. Documented the divergence
   honestly: this is a UX gate; the backend is authoritative; if a
   non-admin operator role is ever granted cancel-any, extend User
   to carry permissions and broaden this check. The backend will
   surface 403 if the heuristic is wrong-positive, which the click
   handler already handles via toast.

4. Minor — comment on ActionCancelOwn/Any overstated default scope.
   Said "every authenticated user role" but RoleReadOnly does NOT
   get cancel-own. Rewrote the comment to enumerate role-by-role
   defaults explicitly.

Plus the type-comment widening in `frontend/src/types.ts`
(CodeRabbit nitpick): the field is populated on every synthetic
purchase_executions row the History endpoint returns, not just
pending/notified ones. Comment now matches.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ Actions performed

Review triggered.

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 session-authed cancel matrix in
TestHandler_cancelPurchase_Session_* only verified the status flip
through SavePurchaseExecution being called with mock.Anything. If
cancelPurchase ever stopped stamping session.Email onto CancelledBy
(or stamped the wrong value) the matrix would still pass because the
saved execution was never inspected.

Capture the *config.PurchaseExecution pointer the mock receives via
mock.Run, then assert Status=="cancelled" and (when session.Email is
set) CancelledBy==session.Email. The History UI's annotateCancelled
helper relies on this audit-stamp for attribution, so locking it
behind a test prevents a silent regression.

Addresses CodeRabbit nitpick on PR #145.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ Actions performed

Review triggered.

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.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
internal/api/handler_purchases_test.go (1)

1137-1149: Tighten mock expectation assertions to reduce false positives.

buildSessionCancelHandler wires auth expectations, but runSessionCancelAllowed does not assert they were actually consumed. Adding expectation assertions will better protect the session-auth security boundary in regression scenarios.

♻️ Proposed test-hardening diff
 func runSessionCancelAllowed(t *testing.T, exec *config.PurchaseExecution, session *Session, hasAny, hasOwn bool) {
 	t.Helper()
-	handler, mockConfig, _ := buildSessionCancelHandler(exec, session, hasAny, hasOwn)
+	handler, mockConfig, mockAuth := buildSessionCancelHandler(exec, session, hasAny, hasOwn)
 	var saved *config.PurchaseExecution
 	mockConfig.On("SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution")).
 		Run(func(args mock.Arguments) {
 			saved = args.Get(1).(*config.PurchaseExecution)
 		}).
 		Return(nil)

 	result, err := handler.cancelPurchase(context.Background(), sessionCancelReq(), cancelExecID, "")
 	require.NoError(t, err)
 	assert.Equal(t, "cancelled", result.(map[string]string)["status"])
+	mockAuth.AssertCalled(t, "ValidateSession", mock.Anything, "sess-tok")
+	mockAuth.AssertExpectations(t)

 	mockConfig.AssertCalled(t, "SavePurchaseExecution", mock.Anything, mock.AnythingOfType("*config.PurchaseExecution"))
 	require.NotNil(t, saved, "SavePurchaseExecution should have captured the execution")
 	assert.Equal(t, "cancelled", saved.Status)
 	if session != nil && session.Email != "" {
 		require.NotNil(t, saved.CancelledBy, "CancelledBy must be stamped when session has an email")
 		assert.Equal(t, session.Email, *saved.CancelledBy, "CancelledBy must equal session.Email for audit attribution")
 	}
 }

Also applies to: 1171-1196

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/api/handler_purchases_test.go` around lines 1137 - 1149, The test
helper buildSessionCancelHandler sets up MockAuthService expectations
(mockAuth.On for ValidateSession, HasPermissionAPI) but the tests (e.g.,
runSessionCancelAllowed and other cases around the 1171-1196 block) never assert
those expectations were met; update the tests to call AssertExpectations on the
mocks after invoking the handler to tighten assertions — specifically call
mockAuth.AssertExpectations(t) and mockConfig.AssertExpectations(t) (or
AssertCalled/AssertNumberOfCalls as appropriate) at the end of
runSessionCancelAllowed and the other session-cancel test flows so the
ValidateSession and HasPermissionAPI expectations set in
buildSessionCancelHandler are actually verified.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 1137-1149: The test helper buildSessionCancelHandler sets up
MockAuthService expectations (mockAuth.On for ValidateSession, HasPermissionAPI)
but the tests (e.g., runSessionCancelAllowed and other cases around the
1171-1196 block) never assert those expectations were met; update the tests to
call AssertExpectations on the mocks after invoking the handler to tighten
assertions — specifically call mockAuth.AssertExpectations(t) and
mockConfig.AssertExpectations(t) (or AssertCalled/AssertNumberOfCalls as
appropriate) at the end of runSessionCancelAllowed and the other session-cancel
test flows so the ValidateSession and HasPermissionAPI expectations set in
buildSessionCancelHandler are actually verified.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 9307b493-51b3-4e14-b1a6-6e515453518b

📥 Commits

Reviewing files that changed from the base of the PR and between aabff3f and 71d1cf7.

📒 Files selected for processing (1)
  • internal/api/handler_purchases_test.go

…sion-cancel branch

CodeRabbit nitpick: `buildSessionCancelHandler` wires `ValidateSession`
and `HasPermissionAPI` expectations on `MockAuthService`, but no test
asserted they were actually consumed. A regression that bypassed
`ValidateSession` (or stopped consulting `HasPermissionAPI` for
non-admins) would silently still pass the status / audit assertions.

Capture `mockAuth` (previously discarded with `_`) in
`runSessionCancelAllowed` and the four reject-path tests
(`CancelOwn_RejectsNonCreator`, `NoVerb_Rejects`,
`RejectsTerminalStatus`, `LegacyNullCreator_NonAdminRejected`), then
call `mockAuth.AssertExpectations(t)` at the end of each.

`HasPermissionAPI` mocks are registered with `.Maybe()` already, so
admin-role tests (which never reach the verb check) still pass.

go test ./internal/api/... clean.
@cristim
Copy link
Copy Markdown
Member Author

cristim commented Apr 27, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

✅ Actions performed

Review triggered.

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 ed6e236 into feat/multicloud-web-frontend Apr 27, 2026
3 checks passed
cristim added a commit that referenced this pull request Apr 27, 2026
…token-entropy

PR #145 (`feat(history): inline Cancel button + cancel-any/own RBAC verbs`)
landed on the base while #101 was in flight. Both touch
`internal/api/handler_purchases.go`'s pending-execution construction in
`executePurchase`:

- #101 hardens the path by extracting `newPendingExecution(req, total,
  savings)` so the approval token comes from `crypto/rand` via
  `common.GenerateApprovalToken` instead of the previous inline
  `uuid.New().String()`.
- #145 adds `CreatedByUserID: resolveCreatorUserID(session)` to every
  pending execution so the cancel-own RBAC path (issue #46) can identify
  the creator on later cancellation.

The two changes are semantically independent — keeping both. Resolution:
keep #101's `newPendingExecution(...)` call, then set
`execution.CreatedByUserID = resolveCreatorUserID(session)` immediately
after construction. The helper stays signature-stable (no session
parameter to plumb) and the cancel-own stamp is in place for every code
path that goes through `executePurchase`.

Other files in the merge auto-resolved cleanly
(`internal/api/coverage_extras_test.go`,
`internal/api/handler_purchases_test.go`,
`internal/api/handler_test.go`).

`go build ./...`, `go test ./...`, `go test ./...` from `pkg/` all clean.
@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/all-users Affects every user effort/l Weeks type/feat New capability type/security Security finding labels Apr 28, 2026
@cristim cristim deleted the feat/history-pending-cancel branch April 29, 2026 10:06
cristim added a commit that referenced this pull request Apr 29, 2026
… threshold + ops-hint (closes #47) (#168)

* feat(history): inline Retry button + retry-any/own RBAC + linkage + threshold + ops-hint

Closes #47.

Salvages a previously-stalled agent attempt: the work was implemented but
never committed; rebased onto current feat/multicloud-web-frontend tip
which now includes PR #145 (cancel button + cancel-{any,own} RBAC +
migration 000041_purchase_executions_created_by_user_id). The retry
plumbing mirrors the cancel pattern row-for-row: same session-validation
gate, same authorize-{any,own} matrix, same legacy-NULL-creator
semantics for non-admins.

Backend
=======
* New POST /api/purchases/retry/{id} (AuthUser). Creates a new
  pending execution from a *failed* row's stored Recommendations and
  links the predecessor with retry_execution_id. Three writes happen
  in one tx (successor INSERT, predecessor UPSERT, suppression INSERT)
  so a crash mid-flow leaves either everything or nothing.
* RBAC: ActionRetryOwn / ActionRetryAny in internal/auth/types.go,
  retry-own:purchases added to DefaultUserPermissions(). Admins
  short-circuit, retry-any holders may retry any row, retry-own
  holders may retry only their own. Test matrix mirrors cancel.
* Already-retried guard: a row whose retry_execution_id is already
  set rejects with 409 + retry_execution_id detail so a stale-cache
  double-tap can't silently overwrite the lineage pointer.
* Persistent-failure block (Q3): handler resolves an ops_hint from a
  small map of known-persistent failure substrings (FROM_EMAIL not
  configured, SES sandbox, etc.) and returns 409 with the hint in
  structured detail fields. The History row also surfaces the same
  hint inline, replacing the Retry button entirely.
* Threshold soft-block (Q2): retry_attempt_n on the new row is
  predecessor.retry_attempt_n + 1; an attempt that would land at >= 5
  is rejected with 409 + retry_attempt_n / threshold details. The
  frontend confirms-with-warning then re-POSTs with ?force=true to
  override.
* Migration 000042 adds nullable retry_execution_id (self-FK ON
  DELETE SET NULL) and retry_attempt_n INT NOT NULL DEFAULT 0,
  plus a partial index on the FK for the rare populated case.
  store_postgres.go INSERT/UPSERT/SELECT cover the new columns;
  retry_execution_id is updateable via ON CONFLICT (the retry
  handler re-saves the predecessor to set the pointer), but
  created_by_user_id and retry_attempt_n stay INSERT-only so the
  scheduler's status-only UPSERTs don't rewrite provenance.
* clientError grew an optional details map so ops_hint /
  retry_attempt_n / threshold can reach the frontend as structured
  JSON fields instead of substring-matched message text.

Frontend
========
* History column doubles as the per-row action surface
  (renderActionCell): Cancel on cancellable pending rows (#46),
  Retry on retryable failed rows (this change), ops-hint badge on
  persistent failures, and inline lineage cross-links on retried
  rows. Status-driven mutually-exclusive decision tree.
* canRetryFailedRow gates the button: status=failed AND no ops_hint
  AND no retry_execution_id (already retried) AND admin OR
  matching created_by_user_id. UX gate only — backend remains
  authoritative.
* Threshold-reached rows render an "⚠ Retried 5× — click to
  override" button; the click confirms then POSTs with force=true.
* RetryPurchaseResult exported from frontend/src/api so callers can
  consume execution_id / retry_attempt_n.

Slice-aliasing fix
==================
The retry handler's new execution borrowed failedExec.Recommendations
by slice header. Mutations downstream
(internal/purchase/execution.go writes
exec.Recommendations[i].{Error,Purchased,PurchaseID}) would have
seen through the alias and corrupted the historical failed row's
in-memory representation visible to any caller still holding the
failedExec pointer. Fixed via defensive copy
(append([]RecommendationRecord(nil), failedExec.Recommendations...))
with a comment explaining the in-process aliasing footgun.

Tests
=====
* 14 new backend table tests covering admin/retry-any/retry-own
  paths, non-failed rejection, legacy-null-creator non-admin
  rejection, persistent-failure block + no-match, threshold
  block + force override, just-under-threshold allow,
  already-retried 409, missing-session rejection.
* 11 new frontend jest tests covering button visibility per role,
  ops-hint replacement, threshold-button text + force=true wiring,
  lineage link rendering, retry_attempt_n badge, declined dialog
  skips API, accepted posts + reloads + toasts, API failure surfaces
  toast and re-enables button.
* Auth permission-count tests bumped 7→8 (default user) and
  9→10 (user + 2 groups) to account for the new retry-own grant.

* fix(history-retry): address CodeRabbit feedback on PR #168

Three actionable findings from CR's first review (PR #168):

1. **Major / security**: authorize before the already-retried guard.
   The `failedExec.RetryExecutionID` 409 ran ahead of
   `authorizeSessionRetry`, so any signed-in user who could guess a
   failed-row UUID would learn it had been retried AND get the
   descendant execution ID — a cross-user info leak. Reordered the
   gate sequence so RBAC runs first; the descendant UUID is now
   surfaced only after the caller has proven they're allowed to act
   on the row. New regression test
   `TestHandler_retryPurchase_AlreadyRetried_RBACBeforeLeak` asserts
   the 403 path doesn't carry retry_execution_id in the details map.

2. **Major / correctness**: preserve PlanID + StepNumber on retry
   successors. Without this, retrying a failed planned execution
   dropped the new row out of plan-scoped history and lost its
   ramp-step attribution. Both fields now propagate from the
   predecessor; for ad-hoc executions PlanID="" and StepNumber=0 so
   propagation is a no-op. New regression test
   `TestHandler_retryPurchase_PreservesPlanMetadata` asserts the
   propagation.

3. **Minor / UX**: surface structured retry hints in the failure
   toast. The catch path used to discard `ops_hint` /
   `retry_attempt_n` / `threshold` from a 4xx body and toast only
   `err.message`, leaving the user with a generic failure instead of
   the operator-actionable hint or override guidance the backend
   already returns. To wire this up:
   - `ApiError` gained an optional `details: Record<string, unknown>`
     field (frontend/src/api/types.ts).
   - `apiRequest` now extracts ALL non-`error` keys from a 4xx body
     into `error.details` (frontend/src/api/client.ts) so callers can
     branch on machine-readable hints without parsing message text.
   - The retry catch in history.ts inspects `err.details.ops_hint`
     first, then falls back to a "already retried n× (threshold k)
     — confirm the override prompt to force" message synthesised
     from `retry_attempt_n` + `threshold`, then to `err.message`.

Plus two nits from the same review:

4. **Defensive copy of clientError details map**. The previous shape
   stored the constructor's input map by reference; a caller mutating
   the map after construction would reach into the error's payload.
   `cloneDetails` shallow-copies on both construction and `Details()`
   read so the error is sealed.

5. **Explicit retry-field assertions in pgxmock GetExecutionByID
   tests**. Two new assertions (one for the NULL/0 legacy-row case,
   one for a populated chain-position-2 case) guard against scan-
   order regressions in the new `retry_execution_id` /
   `retry_attempt_n` columns. A column reorder upstream would scan
   into the wrong destination and fail at least one of these.

All 1337 frontend tests + ~16 backend retry/auth/config tests pass.
gocyclo / gofmt / go vet pre-commit hooks pass.
cristim added a commit that referenced this pull request May 5, 2026
… RBAC (#286) (#299)

* feat(api,history): in-dashboard purchase approval + approve-{any,own} RBAC (closes #286)

Adds an inline Approve button on pending Purchase History rows + the
session-authed backend dispatch that lets admins (and `approve-own`
holders for their own pending rows) approve without round-tripping
to the SES email. Mirrors the same dual-auth shape as the Cancel
button (PR #145) and Retry button (PR #168).

Backend
-------

* `internal/auth/types.go`: new constants `ActionApproveOwn` /
  `ActionApproveAny` next to the existing cancel-/retry- block.
  `DefaultUserPermissions()` adds `approve-own:purchases` so every
  authenticated user can approve pending executions they themselves
  created. `approve-any` exists for future operator roles; no
  default non-admin grant.

* `internal/api/handler_purchases.go::approvePurchase` refactored
  into the same three-mode dispatch as `cancelPurchase`:
    1. Session present + RBAC matches → session-authed approve via
       `approvePurchaseViaSession` regardless of token.
    2. token != "" → legacy email-link path, unchanged.
    3. token == "" → session-authed dashboard branch.
  Permission-denied (403) on the session check falls through to
  the token branch so a logged-in non-admin who is the per-account
  contact_email recipient can still approve via the email link.

* New `approvePurchaseViaSession` mirrors `cancelPurchaseViaSession`
  minus the suppression-cleanup tx (approve doesn't drop
  suppressions — those persist until cancel/expiry). Status flip
  + `ApprovedBy = session.Email` stamp + SavePurchaseExecution.

* New `authorizeSessionApprove` mirror of `authorizeSessionCancel`:
  admin → ok; else `approve-any` → ok; else `approve-own` AND
  `created_by_user_id == session.UserID` → ok; else 403.

No new migration: existing `purchase_executions.approved_by` (TEXT,
migration 000035) and `created_by_user_id` (UUID FK, migration
000041) are sufficient — the email field stamps the actor and the
UUID drives the own-ness check.

Frontend
--------

* `frontend/src/api/purchases.ts`: new `approvePurchase(executionId)`
  caller wrapping `POST /purchases/approve/{id}` with bearer-session
  auth (no token in URL — backend's session-first dispatch picks
  the auth path).
* `frontend/src/api/index.ts`: re-export the new function from the
  api barrel.
* `frontend/src/history.ts`:
    - new `canApprovePendingRow` predicate mirroring
      `canCancelPendingRow` (admin → yes; non-admin → only own
      pending rows; legacy null-creator rows out of reach via
      this UI).
    - `renderActionCell` now renders Approve + Cancel side-by-side
      for pending rows where the session qualifies for both. Each
      predicate is checked independently so a custom role with
      only one verb renders just that button.
    - new click handler on `.history-approve-btn` mirroring the
      cancel-button pattern: confirmDialog (non-destructive) →
      `api.approvePurchase` → reload history + success toast.
      Failed approve surfaces an error toast + re-enables the
      button.

RI exchange
-----------

`approveRIExchange` has the same shape gap (token-only auth) but
mutates a state-machine and triggers downstream cloud-side
exchange execution — bigger surface than fits in this PR. Filing
as a follow-up issue tracking the symmetric work.

Tests
-----

Backend
* `internal/auth/types_test.go` + `service_test.go` +
  `service_group_test.go`: bumped permission-count assertions
  (8→9, 10→11) and added `ActionApproveOwn:ResourcePurchases`
  membership check in `DefaultUserPermissions returns user access`.
* `internal/api/handler_purchases_test.go`: added `.Maybe()`
  HasPermissionAPI mocks (returning false for both verbs) on
  five existing approve-purchase tests so the new session-first
  dispatch falls through to the token branch they exercise.
* `internal/api/coverage_extras_test.go`:
  `TestHandler_approvePurchase_EmptyToken` now asserts the
  empty-token + zero-handler case returns SOME error (recovered
  from panic via `defer recover`) — the empty-token path is no
  longer a pre-flight 400, since after #286 it's the dispatch
  into the session-authed branch.
* `internal/api/handler_test.go::TestHandler_HandleRequest_ApprovePurchase`:
  same `.Maybe()` mocks for the end-to-end approve route test.

Frontend
* New `frontend/src/__tests__/history-approve-button.test.ts`:
  8 tests covering admin/regular/anonymous render branches,
  non-pending-row absence, side-by-side Approve+Cancel render,
  declined confirmDialog, accepted confirm + reload + toast,
  and API-failure error toast + button re-enable.

Verification:
  - `go test ./...` clean (4265 passed, 0 failed, 6 skipped).
  - `npm test` clean (1469 passed, 0 failed).
  - `npm run build` + `npx tsc --noEmit` clean.

Token-only email-link path is unchanged — backwards-compat
mandatory; existing pre-#286 tests on that path continue to pass.

* fix(history,api): disable both row-actions during click + tighten empty-token test (CR pass on PR #299)

Addresses both actionable items from CodeRabbit's first review on PR
#299:

1. **history.ts:603 — disable BOTH Approve and Cancel during a click**
   (Minor / Quick win): after #286 the two buttons render side-by-side
   on pending rows, but the click handlers were disabling only the
   clicked button. A quick double-click could fire conflicting requests
   on the same row before the reload completes.

   Extracted a small `sameRowActions(btn)` helper that returns every
   row-action button (`.history-approve-btn`, `.history-cancel-btn`)
   in the same `<td>` as the clicked button (falls back to `[btn]`
   when no parent cell exists — test fixtures may not wrap in a
   table). Both Approve and Cancel handlers now disable the full set
   while the API is in flight and re-enable the full set on failure.
   Successful requests trigger a full history reload that re-renders
   the row, so the row-action sibling state doesn't matter on the
   happy path.

2. **coverage_extras_test.go:52 — fail loudly on panic instead of
   swallowing** (Major / Quick win): the prior `recover()` swallow
   could let a panic in any new dispatch branch pass the test
   without ever asserting on `err`, masking real regressions on
   the empty-token path.

   The test now (a) `t.Fatalf`s on any panic from `approvePurchase`
   so the panic path is itself a failure signal, and (b) wires a
   minimal `MockConfigStore` returning a clean
   "execution not found" error from `GetExecutionByID` so the
   dispatch reaches a proper `NewClientError(404)` rather than
   nil-deref'ing on `h.config`. `require.Error(...)` then asserts
   the contract: empty token + no session must NOT silently succeed.

Tests (regression coverage for #1):

* `history-approve-button.test.ts` gains two new cases:
  - "approve click disables BOTH Approve and Cancel for the row
    while in flight (CR pass)" — uses an unresolved promise from
    the api mock to capture the in-flight state and asserts both
    buttons are disabled until the promise resolves.
  - "approve API failure re-enables BOTH Approve and Cancel for
    the row (CR pass)" — asserts both buttons are re-enabled
    after the API rejection path runs.

Verification:
  - `go test ./...` clean (4265 passed / 0 failed / 6 skipped).
  - `npm test` clean (1471 passed / 0 failed).
  - `npx tsc --noEmit` clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/l Weeks impact/all-users Affects every user priority/p1 Next up; this sprint severity/high Significant harm triaged Item has been triaged type/feat New capability 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