feat(email): post-execution notification with one-click revoke (closes #291)#889
feat(email): post-execution notification with one-click revoke (closes #291)#889cristim wants to merge 2 commits into
Conversation
…#291) After a purchase execution completes (via both session-RBAC and token approval paths), send a post-execution notification email to the global notification address, per-account contact email(s), and the requester's email (deduplicated, first contact is To, rest are Cc). The email includes a one-click "Revoke this purchase" link backed by the existing approval_token. A new AuthPublic route GET/POST /api/purchases/revoke/{id}?token=... validates the token via crypto/subtle.ConstantTimeCompare (timing-safe), then records revocation_requested status. Session-authed users with cancel-any/own permission may also revoke without a token. New helpers: sendPurchaseExecutedEmail, resolveExecutedNotificationRecipients, revokePurchase, revokeViaSession. PII-safe: log recipient count, never raw addresses. Mock stubs added for scheduler, server, and purchase test packages.
|
@coderabbitai review |
📝 WalkthroughWalkthroughThis PR implements purchase revocation and post-execution email notifications. It extends the email sender interface with a new method, adds email templates for purchase-executed notifications, implements a revocation handler with token and session authentication, integrates post-execution email sending into approval handlers, exposes new revoke endpoints, and updates all test mocks. ChangesPurchase Revocation and Post-Execution Notifications
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
✅ Actions performedReview triggered.
|
…rchase helpers to fit gocyclo budget sendPurchaseExecutedEmail (16 → 9): extract globalNotifyEmail and lookupRequesterInfo to pull the nullable-field branches and auth lookup out of the main flow. revokePurchase (13 → 9): extract checkRevokableStatus, tryRevokeViaSession, and validateRevokeToken, mirroring the pattern used by cancelPurchase and its siblings. No behaviour changes; validation order, error messages, and email sender call shape are identical.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (2)
internal/purchase/mocks_test.go (1)
654-656: ⚡ Quick winUse testify call recording for the new mock method.
This method bypasses
m.Called, so tests can’t assert invocation or force error paths forSendPurchaseExecutedNotification.Proposed fix
func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { - return nil + args := m.Called() + return args.Error(0) }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/purchase/mocks_test.go` around lines 654 - 656, The mock method MockEmailSender.SendPurchaseExecutedNotification currently returns nil directly and skips testify's call recorder, preventing tests from asserting or simulating errors; modify this method to call and return m.Called(...) results (use m.Called(ctx, notificationData) and convert the first return to error) so tests can assert invocation and set return values via m.On/Assert; update the method in the MockEmailSender type to delegate to m.Called instead of returning nil.internal/scheduler/scheduler_test.go (1)
534-536: ⚡ Quick winAlign this mock method with the rest of the testify-backed sender.
Returning
nilunconditionally blocks expectation/assertion coverage forSendPurchaseExecutedNotification.Proposed fix
-func (m *MockEmailSender) SendPurchaseExecutedNotification(_ context.Context, _ email.NotificationData) error { - return nil +func (m *MockEmailSender) SendPurchaseExecutedNotification(ctx context.Context, data email.NotificationData) error { + args := m.Called(ctx, data) + return args.Error(0) }🤖 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/scheduler/scheduler_test.go` around lines 534 - 536, The mock method MockEmailSender.SendPurchaseExecutedNotification currently always returns nil which prevents testify mock expectations from being asserted; modify this method to use the testify mock call pattern (invoke m.Called with the context and notification args or placeholders) and return the error extracted from the call result (e.g., args.Error(0)) so tests can set expectations and control the returned error via mock.On for SendPurchaseExecutedNotification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 2850-2987: Add tests that exercise the session-auth revoke branch
by invoking Handler.revokePurchase without a token in the request so the path
goes through tryRevokeViaSession -> revokeViaSession; create cases for an admin
with HasPermissionAPI returning true for "cancel-any" and a non-admin owner with
"cancel-own" true (and corresponding ValidateSession returning a Session with
matching Email), plus a failure case where neither permission is present to
confirm denial. Ensure the tests mock MockAuth.ValidateSession,
MockAuth.HasPermissionAPI, and
MockConfigStore.GetExecutionByID/GetCloudAccount/SavePurchaseExecution to assert
the execution status transitions (e.g., to "revocation_requested") or the
expected client errors, and reference the functions tryRevokeViaSession,
revokeViaSession and revokePurchase in test names/comments so reviewers can
locate them.
In `@internal/api/handler_purchases.go`:
- Around line 681-685: The current revoke path calls
validateRevokeToken(execution, token) but never verifies the token/window
expiry, allowing old executions to be marked revocation_requested; update the
revoke flow to enforce expiry by adding an expiry/window check before calling
revokeViaSession: either extend validateRevokeToken to also validate token
expiration and execution window timestamps (e.g., compare
execution.RevokeTokenExpiry or execution.WindowEnd against time.Now()) or call a
new helper (e.g., validateRevokeTokenExpiry or isRevokeWindowValid) after
validateRevokeToken and before revokeViaSession(ctx, execution, actor), and
return an error if the token/window is expired; apply the same change to the
other revoke path handling lines ~733-741 so both code paths enforce expiry.
- Line 757: The log call currently prints the raw actor email via revokedBy in
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, revokedBy); change this to emit a PII-safe identifier
instead (e.g., mask or hash the email or log the actor's internal ID). Update
the call to use a helper like redactEmail(revokedBy) or hashActor(revokedBy)
(create such a helper if missing) so the message becomes
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, redactEmail(revokedBy)); ensure redactEmail/hashActor is
deterministic and irreversible to avoid leaking the actual email.
- Around line 748-756: revokeViaSession currently mutates execution.Status and
calls h.config.SavePurchaseExecution, which can cause lost-updates; change it to
perform an atomic/conditional status transition that only sets Status =
"revocation_requested" when the current status is one of the allowed
predecessors ("completed" or "partially_completed"). Locate revokeViaSession and
replace the blind mutation+SavePurchaseExecution with a single conditional
update call (or add a new config method) that issues an UPDATE ... WHERE
execution_id = ? AND status IN ('completed','partially_completed') (or
equivalent ORM/Repo call) and returns a clear error when no rows were updated;
ensure CancelledBy is included in the same atomic update and preserve existing
SavePurchaseExecution behavior for other flows.
---
Nitpick comments:
In `@internal/purchase/mocks_test.go`:
- Around line 654-656: The mock method
MockEmailSender.SendPurchaseExecutedNotification currently returns nil directly
and skips testify's call recorder, preventing tests from asserting or simulating
errors; modify this method to call and return m.Called(...) results (use
m.Called(ctx, notificationData) and convert the first return to error) so tests
can assert invocation and set return values via m.On/Assert; update the method
in the MockEmailSender type to delegate to m.Called instead of returning nil.
In `@internal/scheduler/scheduler_test.go`:
- Around line 534-536: The mock method
MockEmailSender.SendPurchaseExecutedNotification currently always returns nil
which prevents testify mock expectations from being asserted; modify this method
to use the testify mock call pattern (invoke m.Called with the context and
notification args or placeholders) and return the error extracted from the call
result (e.g., args.Error(0)) so tests can set expectations and control the
returned error via mock.On for SendPurchaseExecutedNotification.
🪄 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: d8b413f2-fa20-492c-8162-27871ec7dbd4
📒 Files selected for processing (15)
internal/api/coverage_gaps_test.gointernal/api/handler_purchases.gointernal/api/handler_purchases_test.gointernal/api/middleware.gointernal/api/router.gointernal/email/interfaces.gointernal/email/nop_sender.gointernal/email/sender.gointernal/email/smtp_sender.gointernal/email/templates.gointernal/mocks/email.gointernal/purchase/mocks_test.gointernal/scheduler/scheduler_test.gointernal/server/app_test.gointernal/server/handler_ri_exchange_test.go
| // TestHandler_revokePurchase_ValidToken verifies that a valid token on a | ||
| // completed execution records a revocation_requested status. | ||
| func TestHandler_revokePurchase_ValidToken(t *testing.T) { | ||
| ctx := context.Background() | ||
| execID := "11111111-1111-1111-1111-111111111111" | ||
| token := "abc123validtoken" | ||
| revokerEmail := "contact@acct.example.com" | ||
| accountID := "acct-1" | ||
|
|
||
| exec := &config.PurchaseExecution{ | ||
| ExecutionID: execID, | ||
| ApprovalToken: token, | ||
| Status: "completed", | ||
| Recommendations: []config.RecommendationRecord{ | ||
| {ID: "r1", CloudAccountID: &accountID}, | ||
| }, | ||
| } | ||
|
|
||
| mockStore := new(MockConfigStore) | ||
| mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) | ||
| // authorizeApprovalAction: GetGlobalConfig for global notify | ||
| mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) | ||
| // GetCloudAccount returns the contact email so authorizeApprovalAction can | ||
| // resolve the approver and verify the session's email matches. | ||
| mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { | ||
| return &config.CloudAccount{ID: id, ContactEmail: revokerEmail}, nil | ||
| } | ||
| // SavePurchaseExecution for the revocation_requested update. | ||
| mockStore.On("SavePurchaseExecution", ctx, mock.MatchedBy(func(e *config.PurchaseExecution) bool { | ||
| return e.ExecutionID == execID && e.Status == "revocation_requested" | ||
| })).Return(nil) | ||
|
|
||
| mockAuth := new(MockAuthService) | ||
| // Provide a session for the revoker so authorizeApprovalAction resolves actor. | ||
| mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: revokerEmail}, nil) | ||
| // RBAC: revoker has no cancel-any or cancel-own, so falls through to token path. | ||
| mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe() | ||
| mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe() | ||
|
|
||
| handler := &Handler{config: mockStore, auth: mockAuth} | ||
|
|
||
| req := &events.LambdaFunctionURLRequest{ | ||
| Headers: map[string]string{"authorization": "Bearer sess-tok"}, | ||
| QueryStringParameters: map[string]string{"token": token}, | ||
| } | ||
| result, err := handler.revokePurchase(ctx, req, execID, token) | ||
| require.NoError(t, err, "valid token on completed execution must not error") | ||
| resultMap := result.(map[string]string) | ||
| assert.Equal(t, "revocation_requested", resultMap["status"]) | ||
| mockStore.AssertExpectations(t) | ||
| } | ||
|
|
||
| // TestHandler_revokePurchase_InvalidToken verifies that a wrong token returns 403. | ||
| // The session provides an email that matches the contact email (so | ||
| // authorizeApprovalAction passes), but the token itself is wrong. | ||
| func TestHandler_revokePurchase_InvalidToken(t *testing.T) { | ||
| ctx := context.Background() | ||
| execID := "22222222-2222-2222-2222-222222222222" | ||
| contactEmail := "contact@acct.example.com" | ||
| accountID := "acct-1" | ||
|
|
||
| exec := &config.PurchaseExecution{ | ||
| ExecutionID: execID, | ||
| ApprovalToken: "the-real-token", | ||
| Status: "completed", | ||
| Recommendations: []config.RecommendationRecord{ | ||
| {ID: "r1", CloudAccountID: &accountID}, | ||
| }, | ||
| } | ||
|
|
||
| mockStore := new(MockConfigStore) | ||
| mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) | ||
| mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) | ||
| mockStore.GetCloudAccountFn = func(_ context.Context, id string) (*config.CloudAccount, error) { | ||
| return &config.CloudAccount{ID: id, ContactEmail: contactEmail}, nil | ||
| } | ||
|
|
||
| mockAuth := new(MockAuthService) | ||
| mockAuth.On("ValidateSession", ctx, "sess-tok").Return(&Session{Email: contactEmail}, nil) | ||
| mockAuth.On("HasPermissionAPI", ctx, "", "cancel-any", "purchases").Return(false, nil).Maybe() | ||
| mockAuth.On("HasPermissionAPI", ctx, "", "cancel-own", "purchases").Return(false, nil).Maybe() | ||
|
|
||
| handler := &Handler{config: mockStore, auth: mockAuth} | ||
|
|
||
| req := &events.LambdaFunctionURLRequest{ | ||
| Headers: map[string]string{"authorization": "Bearer sess-tok"}, | ||
| QueryStringParameters: map[string]string{"token": "wrong-token"}, | ||
| } | ||
| _, err := handler.revokePurchase(ctx, req, execID, "wrong-token") | ||
| require.Error(t, err) | ||
| ce, ok := IsClientError(err) | ||
| require.True(t, ok, "expected a client error") | ||
| assert.Equal(t, 403, ce.code) | ||
| } | ||
|
|
||
| // TestHandler_revokePurchase_PendingExecution verifies that a pending execution | ||
| // returns 409 with a friendly message directing the user to Cancel instead. | ||
| func TestHandler_revokePurchase_PendingExecution(t *testing.T) { | ||
| ctx := context.Background() | ||
| execID := "33333333-3333-3333-3333-333333333333" | ||
|
|
||
| exec := &config.PurchaseExecution{ | ||
| ExecutionID: execID, | ||
| ApprovalToken: "tok", | ||
| Status: "pending", | ||
| } | ||
|
|
||
| mockStore := new(MockConfigStore) | ||
| mockStore.On("GetExecutionByID", ctx, execID).Return(exec, nil) | ||
|
|
||
| handler := &Handler{config: mockStore} | ||
| req := &events.LambdaFunctionURLRequest{ | ||
| QueryStringParameters: map[string]string{"token": "tok"}, | ||
| } | ||
| _, err := handler.revokePurchase(ctx, req, execID, "tok") | ||
| require.Error(t, err) | ||
| ce, ok := IsClientError(err) | ||
| require.True(t, ok, "expected a client error") | ||
| assert.Equal(t, 409, ce.code) | ||
| assert.Contains(t, ce.message, "Cancel") | ||
| } | ||
|
|
||
| // TestHandler_revokePurchase_NotFound verifies 404 when the execution does not exist. | ||
| func TestHandler_revokePurchase_NotFound(t *testing.T) { | ||
| ctx := context.Background() | ||
| execID := "44444444-4444-4444-4444-444444444444" | ||
|
|
||
| mockStore := new(MockConfigStore) | ||
| mockStore.On("GetExecutionByID", ctx, execID).Return(nil, nil) | ||
|
|
||
| handler := &Handler{config: mockStore} | ||
| req := &events.LambdaFunctionURLRequest{} | ||
| _, err := handler.revokePurchase(ctx, req, execID, "some-token") | ||
| require.Error(t, err) | ||
| ce, ok := IsClientError(err) | ||
| require.True(t, ok, "expected a client error") | ||
| assert.Equal(t, 404, ce.code) | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Add tests for the session-auth revoke branch
The new suite validates token-mode revoke, but it doesn’t pin the tokenless session-authorized revoke path (tryRevokeViaSession → revokeViaSession). That’s a primary feature path and should be covered explicitly (admin/cancel-any/cancel-own and no-token request).
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/api/handler_purchases_test.go` around lines 2850 - 2987, Add tests
that exercise the session-auth revoke branch by invoking Handler.revokePurchase
without a token in the request so the path goes through tryRevokeViaSession ->
revokeViaSession; create cases for an admin with HasPermissionAPI returning true
for "cancel-any" and a non-admin owner with "cancel-own" true (and corresponding
ValidateSession returning a Session with matching Email), plus a failure case
where neither permission is present to confirm denial. Ensure the tests mock
MockAuth.ValidateSession, MockAuth.HasPermissionAPI, and
MockConfigStore.GetExecutionByID/GetCloudAccount/SavePurchaseExecution to assert
the execution status transitions (e.g., to "revocation_requested") or the
expected client errors, and reference the functions tryRevokeViaSession,
revokeViaSession and revokePurchase in test names/comments so reviewers can
locate them.
| if err := validateRevokeToken(execution, token); err != nil { | ||
| return nil, err | ||
| } | ||
| return h.revokeViaSession(ctx, execution, actor) | ||
| } |
There was a problem hiding this comment.
Enforce token expiry/window on revoke token path
Line 681 validates token equality, but never checks token/window expiry before accepting revocation. With current logic, an old completed execution can still be marked revocation_requested as long as the token matches.
Suggested fix
func validateRevokeToken(execution *config.PurchaseExecution, token string) error {
if execution.ApprovalToken == "" {
return NewClientError(403, "invalid revocation token")
}
+ if execution.ApprovalTokenExpiresAt != nil && time.Now().After(*execution.ApprovalTokenExpiresAt) {
+ return NewClientError(409, fmt.Sprintf(
+ "execution %s cannot be revoked: revocation link has expired", execution.ExecutionID))
+ }
if subtle.ConstantTimeCompare([]byte(execution.ApprovalToken), []byte(token)) != 1 {
return NewClientError(403, "invalid revocation token")
}
return nil
}Also applies to: 733-741
🤖 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 681 - 685, The current revoke
path calls validateRevokeToken(execution, token) but never verifies the
token/window expiry, allowing old executions to be marked revocation_requested;
update the revoke flow to enforce expiry by adding an expiry/window check before
calling revokeViaSession: either extend validateRevokeToken to also validate
token expiration and execution window timestamps (e.g., compare
execution.RevokeTokenExpiry or execution.WindowEnd against time.Now()) or call a
new helper (e.g., validateRevokeTokenExpiry or isRevokeWindowValid) after
validateRevokeToken and before revokeViaSession(ctx, execution, actor), and
return an error if the token/window is expired; apply the same change to the
other revoke path handling lines ~733-741 so both code paths enforce expiry.
| func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) { | ||
| execution.Status = "revocation_requested" | ||
| if revokedBy != "" { | ||
| rb := revokedBy | ||
| execution.CancelledBy = &rb | ||
| } | ||
| if err := h.config.SavePurchaseExecution(ctx, execution); err != nil { | ||
| return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err) | ||
| } |
There was a problem hiding this comment.
Use an atomic status transition for revocation_requested
revokeViaSession mutates the loaded struct then calls SavePurchaseExecution, which is vulnerable to race/lost-update behavior if the row changes between read and write. This should use a conditional transition (completed|partially_completed -> revocation_requested) like other state transitions.
Suggested fix
func (h *Handler) revokeViaSession(ctx context.Context, execution *config.PurchaseExecution, revokedBy string) (any, error) {
- execution.Status = "revocation_requested"
- if revokedBy != "" {
- rb := revokedBy
- execution.CancelledBy = &rb
- }
- if err := h.config.SavePurchaseExecution(ctx, execution); err != nil {
- return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err)
- }
+ updated, err := h.config.TransitionExecutionStatus(ctx, execution.ExecutionID, []string{"completed", "partially_completed"}, "revocation_requested")
+ if err != nil {
+ return nil, NewClientError(409, fmt.Sprintf("execution %s cannot be revoked: %v", execution.ExecutionID, err))
+ }
+ if revokedBy != "" {
+ rb := revokedBy
+ updated.CancelledBy = &rb
+ if err := h.config.SavePurchaseExecution(ctx, updated); err != nil {
+ return nil, fmt.Errorf("failed to record revocation requester for execution %s: %w", execution.ExecutionID, err)
+ }
+ }🤖 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 748 - 756, revokeViaSession
currently mutates execution.Status and calls h.config.SavePurchaseExecution,
which can cause lost-updates; change it to perform an atomic/conditional status
transition that only sets Status = "revocation_requested" when the current
status is one of the allowed predecessors ("completed" or
"partially_completed"). Locate revokeViaSession and replace the blind
mutation+SavePurchaseExecution with a single conditional update call (or add a
new config method) that issues an UPDATE ... WHERE execution_id = ? AND status
IN ('completed','partially_completed') (or equivalent ORM/Repo call) and returns
a clear error when no rows were updated; ensure CancelledBy is included in the
same atomic update and preserve existing SavePurchaseExecution behavior for
other flows.
| if err := h.config.SavePurchaseExecution(ctx, execution); err != nil { | ||
| return nil, fmt.Errorf("failed to record revocation request for execution %s: %w", execution.ExecutionID, err) | ||
| } | ||
| logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy) |
There was a problem hiding this comment.
Avoid logging raw actor email in revoke path
Line 757 logs revokedBy directly. That leaks PII into logs and conflicts with the PR’s stated PII-safe logging behavior.
Suggested fix
- logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy)
+ logging.Infof("Revocation requested for execution %s (actor_present=%t)", execution.ExecutionID, revokedBy != "")📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| logging.Infof("Revocation requested for execution %s by %s", execution.ExecutionID, revokedBy) | |
| logging.Infof("Revocation requested for execution %s (actor_present=%t)", execution.ExecutionID, revokedBy != "") |
🤖 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` at line 757, The log call currently prints
the raw actor email via revokedBy in logging.Infof("Revocation requested for
execution %s by %s", execution.ExecutionID, revokedBy); change this to emit a
PII-safe identifier instead (e.g., mask or hash the email or log the actor's
internal ID). Update the call to use a helper like redactEmail(revokedBy) or
hashActor(revokedBy) (create such a helper if missing) so the message becomes
logging.Infof("Revocation requested for execution %s by %s",
execution.ExecutionID, redactEmail(revokedBy)); ensure redactEmail/hashActor is
deterministic and irreversible to avoid leaking the actual email.
Summary
notification_email, per-accountcontact_email(s), and the requester's email. Recipients are deduplicated; first contact is To, rest are Cc.approval_token(a dedicated revocation token column is deferred to the sibling AWS RI/SP revocation-via-support-case issue).AuthPublicrouteGET/POST /api/purchases/revoke/{id}?token=...validates the token withcrypto/subtle.ConstantTimeCompare(timing-safe) and recordsrevocation_requestedstatus. Session-authed users withcancel-anyorcancel-ownpermission can also revoke without a token (same three-mode dispatch as approve/cancel).Test plan
TestHandler_revokePurchase_ValidToken- valid token path resolves torevocation_requestedTestHandler_revokePurchase_InvalidToken- wrong token returns 403TestHandler_revokePurchase_PendingExecution- pending purchase returns 409 directing to CancelTestHandler_revokePurchase_NotFound- missing execution returns 404TestResolveExecutedNotificationRecipients_*- four dedup/fallback scenarios coveredSummary by CodeRabbit