Skip to content

Commit 681efb5

Browse files
committed
fix(ri-exchange): address CodeRabbit findings on PR #590 (review 4357903828)
Finding 1 (Major): canApproveRIExchangeRow now uses canAccess() for both admin/* and approve-any:purchases holders; approve-own:purchases replaces the bare user-id equality check. Non-admin approve-any holders can now see and click the Approve button. Finding 2 (Minor): split approveRIExchange's single try/catch into two blocks so a refresh failure no longer triggers the "Failed to approve" error toast. Approval and history reload are independent failures. Finding 3 (Minor): three test call sites that silently discarded the returned err now capture it and assert require.NoError, ensuring regressions surface instead of passing on side-effect checks alone. Finding 4 (Major): when sessionHasApproveRight passes but approveRIExchangeViaSession returns permission-denied (approve-own user not the creator), fall through to the token path if a token is present instead of returning the 403. Legacy email-link approval now works for this RBAC shape.
1 parent 7293268 commit 681efb5

3 files changed

Lines changed: 44 additions & 20 deletions

File tree

‎frontend/src/riexchange.ts‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,9 +1014,9 @@ function canApproveRIExchangeRow(rec: RIExchangeHistoryRecord): boolean {
10141014
if ((rec.status || '').toLowerCase() !== 'pending') return false;
10151015
const user = getCurrentUser();
10161016
if (!user) return false;
1017-
if (user.role === 'admin') return true;
1017+
if (canAccess('admin', '*') || canAccess('approve-any', 'purchases')) return true;
10181018
if (!rec.created_by_user_id) return false;
1019-
return rec.created_by_user_id === user.id;
1019+
return canAccess('approve-own', 'purchases') && rec.created_by_user_id === user.id;
10201020
}
10211021

10221022
async function loadExchangeHistory(): Promise<void> {
@@ -1118,10 +1118,16 @@ async function handleRIExchangeApproveClick(btn: HTMLButtonElement): Promise<voi
11181118
try {
11191119
await api.approveRIExchange(id);
11201120
showToast({ kind: 'success', message: 'RI exchange approved and executing.' });
1121-
await loadExchangeHistory();
11221121
} catch (err) {
11231122
const msg = err instanceof Error ? err.message : String(err);
11241123
showToast({ kind: 'error', message: 'Failed to approve exchange: ' + msg });
11251124
btn.disabled = false;
1125+
return;
1126+
}
1127+
try {
1128+
await loadExchangeHistory();
1129+
} catch (err) {
1130+
const msg = err instanceof Error ? err.message : String(err);
1131+
showToast({ kind: 'error', message: 'Approved, but failed to refresh history: ' + msg });
11261132
}
11271133
}

‎internal/api/handler_ri_exchange.go‎

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,15 @@ func (h *Handler) approveRIExchange(ctx context.Context, req *events.LambdaFunct
793793
// through to the token branch so email-link holders can still approve.
794794
switch err := h.sessionHasApproveRight(ctx, session); {
795795
case err == nil:
796-
return h.approveRIExchangeViaSession(ctx, req, id, session)
796+
result, sessErr := h.approveRIExchangeViaSession(ctx, req, id, session)
797+
if sessErr == nil {
798+
return result, nil
799+
}
800+
// Record-level RBAC denied (e.g. approve-own user is not the creator).
801+
// If a token is present, preserve legacy token flow; otherwise surface the error.
802+
if !(token != "" && isPermissionDenied(sessErr)) {
803+
return nil, sessErr
804+
}
797805
case isPermissionDenied(err):
798806
// Logged-in user without approve-* may still hold a valid email token.
799807
default:
@@ -802,23 +810,30 @@ func (h *Handler) approveRIExchange(ctx context.Context, req *events.LambdaFunct
802810
}
803811

804812
if token != "" {
805-
record, err := h.validateExchangeApproval(ctx, id, token)
806-
if err != nil {
807-
return nil, err
808-
}
813+
return h.approveRIExchangeViaToken(ctx, id, token)
814+
}
809815

810-
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
811-
if err != nil {
812-
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
813-
}
814-
if transitioned == nil {
815-
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run")
816-
}
816+
return h.approveRIExchangeViaSession(ctx, req, id, nil)
817+
}
817818

818-
return h.executeApprovedExchange(ctx, id, record)
819+
// approveRIExchangeViaToken is the legacy email-link branch of approveRIExchange.
820+
// It validates the approval token, transitions the exchange to processing, and
821+
// executes it. Extracted to keep approveRIExchange within cyclomatic-complexity limits.
822+
func (h *Handler) approveRIExchangeViaToken(ctx context.Context, id, token string) (any, error) {
823+
record, err := h.validateExchangeApproval(ctx, id, token)
824+
if err != nil {
825+
return nil, err
819826
}
820827

821-
return h.approveRIExchangeViaSession(ctx, req, id, nil)
828+
transitioned, err := h.config.TransitionRIExchangeStatus(ctx, id, "pending", "processing")
829+
if err != nil {
830+
return nil, fmt.Errorf("failed to transition exchange status: %w", err)
831+
}
832+
if transitioned == nil {
833+
return nil, NewClientError(409, "exchange already processed, expired, or was cancelled by a newer analysis run")
834+
}
835+
836+
return h.executeApprovedExchange(ctx, id, record)
822837
}
823838

824839
// approveRIExchangeViaSession is the session-authed branch of approveRIExchange

‎internal/api/handler_ri_exchange_test.go‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,8 @@ func TestApproveRIExchange_SessionAdmin(t *testing.T) {
353353
req := &events.LambdaFunctionURLRequest{
354354
Headers: map[string]string{"authorization": "Bearer admin-bearer"},
355355
}
356-
_, _ = h.approveRIExchange(ctx, req, id, "")
356+
_, err := h.approveRIExchange(ctx, req, id, "")
357+
require.NoError(t, err)
357358
// The exchange execution will fail (no real AWS SDK) but we verify the
358359
// dispatch reached the session-authed path.
359360
mockStore.AssertCalled(t, "GetRIExchangeRecord", ctx, id)
@@ -403,7 +404,8 @@ func TestApproveRIExchange_SessionApproveOwn(t *testing.T) {
403404
req := &events.LambdaFunctionURLRequest{
404405
Headers: map[string]string{"authorization": "Bearer owner-bearer"},
405406
}
406-
_, _ = h.approveRIExchange(ctx, req, id, "")
407+
_, err := h.approveRIExchange(ctx, req, id, "")
408+
require.NoError(t, err)
407409
mockStore.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing")
408410
})
409411

@@ -463,7 +465,8 @@ func TestApproveRIExchange_LegacyTokenStillWorks(t *testing.T) {
463465
mockStore.On("FailRIExchange", ctx, id, mock.AnythingOfType("string")).Return(nil)
464466

465467
req := &events.LambdaFunctionURLRequest{}
466-
_, _ = h.approveRIExchange(ctx, req, id, token)
468+
_, err := h.approveRIExchange(ctx, req, id, token)
469+
require.NoError(t, err)
467470
mockStore.AssertCalled(t, "TransitionRIExchangeStatus", ctx, id, "pending", "processing")
468471
}
469472

0 commit comments

Comments
 (0)