Skip to content

feat(email): post-execution notification with one-click revoke (closes #291) - #889

Merged
cristim merged 20 commits into
mainfrom
fix/291-wave2
Jul 16, 2026
Merged

feat(email): post-execution notification with one-click revoke (closes #291)#889
cristim merged 20 commits into
mainfrom
fix/291-wave2

Conversation

@cristim

@cristim cristim commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

  • After a purchase execution completes (both session-RBAC and token approval paths), send a post-execution notification email to: global notification_email, per-account contact_email(s), and the requester's email. Recipients are deduplicated; first contact is To, rest are Cc.
  • The email includes purchase details and a one-click "Revoke this purchase" button backed by the existing approval_token (a dedicated revocation token column is deferred to the sibling AWS RI/SP revocation-via-support-case issue).
  • New AuthPublic route GET/POST /api/purchases/revoke/{id}?token=... validates the token with crypto/subtle.ConstantTimeCompare (timing-safe) and records revocation_requested status. Session-authed users with cancel-any or cancel-own permission can also revoke without a token (same three-mode dispatch as approve/cancel).

Test plan

  • TestHandler_revokePurchase_ValidToken - valid token path resolves to revocation_requested
  • TestHandler_revokePurchase_InvalidToken - wrong token returns 403
  • TestHandler_revokePurchase_PendingExecution - pending purchase returns 409 directing to Cancel
  • TestHandler_revokePurchase_NotFound - missing execution returns 404
  • TestResolveExecutedNotificationRecipients_* - four dedup/fallback scenarios covered
  • Full test suite: 4974 passed, 0 failed across 38 packages
  • PII-safe logging: recipient count only, no raw email addresses in logs

Summary by CodeRabbit

  • New Features

    • Add purchase revocation for completed transactions (session or token-based) and public token endpoints for one-click revokes.
    • Send post-execution notification emails with execution details and one-click revocation links; recipients include global contacts, account contacts, and requester (deduped).
  • Tests

    • Expanded coverage for revocation flows, token/expiry cases, recipient selection, and a concurrency regression to prevent lost updates.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy urgency/this-quarter Within the quarter impact/many Affects most users effort/s Hours type/feat New capability labels May 30, 2026
@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 8 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

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.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 47ea2f85-47c2-4610-8892-dd28e4fd5a2e

📥 Commits

Reviewing files that changed from the base of the PR and between 15c934b and a7961a1.

📒 Files selected for processing (12)
  • internal/analytics/collector_test.go
  • internal/api/coverage_gaps_test.go
  • internal/api/executed_notification_flow_test.go
  • internal/api/handler.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/middleware.go
  • internal/api/middleware_test.go
  • internal/api/router.go
  • internal/config/constants.go
  • internal/config/interfaces.go
  • internal/config/store_postgres.go
📝 Walkthrough

Walkthrough

Adds post-execution purchase notifications (emails with optional one‑click revoke link), a token/session revocation handler and public routes, extends the email sender contract and templates, integrates best-effort sends into approval flows, and updates tests/mocks accordingly.

Changes

Purchase Revocation and Post-Execution Notifications

Layer / File(s) Summary
Email Interface and Notification Data Contract
internal/email/interfaces.go, internal/email/sender.go
SenderInterface gains SendPurchaseExecutedNotification; NotificationData gains RevocationToken, RevocationWindowClosesAt, ExecutedAt, ExecutedBy.
Email templates and sender implementations
internal/email/templates.go, internal/email/smtp_sender.go, internal/email/nop_sender.go, internal/email/sender.go, internal/mocks/email.go
Adds plain-text and HTML executed-email templates, rendering and multipart send helper, and implements SendPurchaseExecutedNotification on Sender, SMTPSender, NopSender; updates mocks.
Router and middleware changes
internal/api/router.go, internal/api/middleware.go
Registers public /api/purchases/revoke/ GET/POST routes mapped to revokePurchaseHandler and refactors isPublicEndpoint to use prefix vs exact matching.
Revocation handler and token validation
internal/api/handler_purchases.go
Adds revokePurchase with session/token/401 dispatch, eligibility checks (completed/partially_completed), constant-time token validation, and session-path helpers to record revocation_requested.
Post-execution email integration
internal/api/handler_purchases.go
Both token-auth and session-auth approval paths trigger sendPurchaseExecutedEmail after execution; helpers resolve global notify email, requester lookup, dedupe To/Cc recipients, and populate template fields; email failures are logged and do not fail the approval.
Revocation & recipient-resolution tests
internal/api/handler_purchases_test.go
Adds tests for revoke token and session branches, pending/missing/expired token cases, concurrent-transition regression, and unit tests for recipient resolution and deduplication.
Test mock and stub updates
internal/mocks/email.go, internal/purchase/mocks_test.go, internal/scheduler/scheduler_test.go, internal/api/coverage_gaps_test.go, internal/server/app_test.go, internal/server/handler_ri_exchange_test.go
All test doubles updated to implement SendPurchaseExecutedNotification (no-op or testify mock wiring) to satisfy the extended email sender interface.

Sequence Diagram

sequenceDiagram
  participant Client
  participant Router
  participant Handler
  participant ExecutionStore
  participant EmailSender
  Client->>Router: GET/POST /api/purchases/revoke/:id?token=...
  Router->>Handler: revokePurchase(req, id, token)
  Handler->>ExecutionStore: checkRevokableStatus(id)
  alt token path
    Handler->>ExecutionStore: validateRevokeToken(token)
  else session path
    Handler->>ExecutionStore: revokeViaSession(session)
  end
  Handler->>EmailSender: (best-effort) SendPurchaseExecutedNotification(NotificationData)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • LeanerCloud/CUDly#613: Also modifies handler_purchases.go execution/approval paths and touches post-approval handling.
  • LeanerCloud/CUDly#299: Related changes to approval/session dispatch in handler_purchases.go.
  • LeanerCloud/CUDly#373: Earlier work making approvals execute synchronously; this PR sends notifications at that execution point.

Suggested labels

severity/medium, effort/m

Poem

A rabbit hops through revocation's door,
Tokens gleam, one-click cancel to explore,
An inbox hums when purchases run,
Deduped recipients, grouped as one,
🐰✉️ Soft logs whisper: "send, but ignore the roar."

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main feature: post-execution notification with one-click revoke capability, directly matching the core functionality added across email, handler, and revocation logic.
Docstring Coverage ✅ Passed Docstring coverage is 95.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ 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 fix/291-wave2

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

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ 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 commented Jun 1, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor
✅ 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.

@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: 4

🧹 Nitpick comments (2)
internal/purchase/mocks_test.go (1)

654-656: ⚡ Quick win

Use testify call recording for the new mock method.

This method bypasses m.Called, so tests can’t assert invocation or force error paths for SendPurchaseExecutedNotification.

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 win

Align this mock method with the rest of the testify-backed sender.

Returning nil unconditionally blocks expectation/assertion coverage for SendPurchaseExecutedNotification.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 4956d66 and 1be3f5c.

📒 Files selected for processing (15)
  • internal/api/coverage_gaps_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/middleware.go
  • internal/api/router.go
  • internal/email/interfaces.go
  • internal/email/nop_sender.go
  • internal/email/sender.go
  • internal/email/smtp_sender.go
  • internal/email/templates.go
  • internal/mocks/email.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/app_test.go
  • internal/server/handler_ri_exchange_test.go

Comment thread internal/api/handler_purchases_test.go
Comment thread internal/api/handler_purchases.go
Comment thread internal/api/handler_purchases.go
Comment thread internal/api/handler_purchases.go Outdated
@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

Rebased onto fresh feat/multicloud-web-frontend (now includes #941's session.Role removal, so the prior base build break is gone) and addressed all CR pass-1 findings. go build ./..., go vet, gofmt, and the touched-package tests are all green; check-migration-conflicts passes (this PR adds no migration).

Addressed CR pass-1 findings (commit bf1c8e36):

  • Enforce token expiry/window on revoke token path (handler_purchases.go:685, also 733-741) -> fixed: validateRevokeToken now rejects an expired ApprovalTokenExpiresAt with 409 before the constant-time match, mirroring ApproveExecution. Legacy pre-migration rows (nil expiry) still pass. Regression test TestHandler_revokePurchase_ExpiredToken.
  • Use an atomic status transition for revocation_requested (handler_purchases.go:756) -> fixed: revokeViaSession now uses the existing TransitionExecutionStatus conditional UPDATE (completed|partially_completed -> revocation_requested), closing the lost-update race; CAS rejection -> 409, vanished row -> 404. Regression test TestHandler_revokeViaSession_ConcurrentTransition.
  • Avoid logging raw actor email in revoke path (handler_purchases.go:757) -> fixed: the log line now routes revokedBy through the existing redactEmail helper.
  • Add tests for the session-auth revoke branch (handler_purchases_test.go:2987) -> fixed: added TestHandler_revokePurchase_SessionAdminCancelAny, ..._SessionOwnerCancelOwn, and ..._SessionNoPermissionNoToken (no-token denial).
  • Use testify call recording for the new mock method (internal/purchase/mocks_test.go:654-656, internal/scheduler/scheduler_test.go:534-536) -> fixed: both MockEmailSender.SendPurchaseExecutedNotification mocks now delegate to m.Called(ctx, data) so expectations are assertable.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the thorough follow-up. All five pass-1 findings look well-addressed:

  • Token expiryvalidateRevokeToken with 409-before-match mirrors ApproveExecution; nil-expiry legacy rows still pass.
  • Atomic CAS transitionTransitionExecutionStatus closes the lost-update race; 409 on CAS rejection, 404 on vanished row.
  • PII-safe loggingredactEmail applied to revokedBy.
  • Session-auth test coverage — cancel-any admin, cancel-own owner, and no-token denial all covered.
  • Testify call recording — both mocks now delegate to m.Called(ctx, data).

Triggering an incremental review of the new commits 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: 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.

@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.

Caution

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

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

16-37: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use exact matching for fixed public endpoints like "/version"

Line 20 adds "/version" to a prefix-matched list; that makes any "/version*" path bypass validateSecurity (auth + CSRF short-circuit). Keep prefix checks only for intentional dynamic routes and exact-match singleton endpoints.

Suggested hardening
 func (h *Handler) isPublicEndpoint(path string) bool {
-	publicEndpoints := []string{
+	publicPrefixEndpoints := []string{
 		"/health",     // Root health endpoint (no /api prefix)
 		"/api/health", // API health endpoint
 		"/api/info",
-		"/version", // Public build-version endpoint (version / git SHA / build time)
 		"/api/purchases/approve/",
 		"/api/purchases/cancel/",
 		"/api/purchases/revoke/",
 		"/api/ri-exchange/approve/",
 		"/api/ri-exchange/reject/",
 		"/api/auth/login",
@@
 		"/docs",
 		"/api/docs",
 	}
-	for _, ep := range publicEndpoints {
+	for _, ep := range publicPrefixEndpoints {
 		if strings.HasPrefix(path, ep) {
 			return true
 		}
 	}
+	// Exact-match singleton public endpoints.
+	if path == "/version" {
+		return true
+	}
 	// Exact match for POST /api/register (no trailing slash).
 	if path == "/api/register" {
 		return true
 	}
 	return false
 }
🤖 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/middleware.go` around lines 16 - 37, The current publicEndpoints
slice is checked with strings.HasPrefix in the middleware that implements
validateSecurity, which lets fixed endpoints like "/version" match "/version*"
unintentionally; update the logic to distinguish exact-match endpoints from
prefix-matched dynamic routes: keep entries intended as prefixes with trailing
slashes (e.g., "/api/purchases/") and create a separate exactEndpoints set (or
check equality) for singleton paths like "/version", "/health", "/api/health",
"/api/info", "/docs", "/api/docs", and other non-dynamic routes, then in the
loop use exact equality for exactEndpoints and strings.HasPrefix only for
prefix-style entries so fixed endpoints no longer bypass auth/CSRF.
🧹 Nitpick comments (1)
internal/api/handler_purchases_test.go (1)

3434-3479: ⚡ Quick win

Add an expired+wrong-token precedence assertion.

This test uses a matching token, so it doesn’t pin the contract that expiry is checked before token comparison. Add a case where the supplied token is wrong but ApprovalTokenExpiresAt is past, and still expect 409 expired.

💡 Suggested test addition
 func TestHandler_revokePurchase_ExpiredToken(t *testing.T) {
@@
 	assert.Equal(t, 409, ce.code)
 	assert.Contains(t, ce.message, "expired")
 }
+
+func TestHandler_revokePurchase_ExpiredToken_WrongTokenStill409(t *testing.T) {
+	ctx := context.Background()
+	execID := "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"
+	realToken := "real-token"
+	wrongToken := "wrong-token"
+	contactEmail := "contact@acct.example.com"
+	accountID := "acct-1"
+	past := time.Now().Add(-1 * time.Hour)
+
+	exec := &config.PurchaseExecution{
+		ExecutionID:            execID,
+		ApprovalToken:          realToken,
+		ApprovalTokenExpiresAt: &past,
+		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": wrongToken},
+	}
+	_, err := handler.revokePurchase(ctx, req, execID, wrongToken)
+	require.Error(t, err)
+	ce, ok := IsClientError(err)
+	require.True(t, ok)
+	assert.Equal(t, 409, ce.code)
+	assert.Contains(t, ce.message, "expired")
+}
🤖 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 3434 - 3479, Add a new
assertion in TestHandler_revokePurchase_ExpiredToken to verify expiry takes
precedence over token equality: create a second call where the PurchaseExecution
returned by mockStore (from GetExecutionByID) has ApprovalTokenExpiresAt in the
past but the supplied query token is a non-matching value (e.g., wrongToken);
call handler.revokePurchase(ctx, reqWrongToken, execID, wrongToken) and assert
the error is a client error with code 409 and message contains "expired". Keep
the same mocks (mockStore, mockAuth/GetCloudAccountFn) and request headers as
the existing test so only the supplied token differs. Ensure you reference
handler.revokePurchase, TestHandler_revokePurchase_ExpiredToken,
GetExecutionByID and ApprovalTokenExpiresAt when locating where to add the new
case.
🤖 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.

Outside diff comments:
In `@internal/api/middleware.go`:
- Around line 16-37: The current publicEndpoints slice is checked with
strings.HasPrefix in the middleware that implements validateSecurity, which lets
fixed endpoints like "/version" match "/version*" unintentionally; update the
logic to distinguish exact-match endpoints from prefix-matched dynamic routes:
keep entries intended as prefixes with trailing slashes (e.g.,
"/api/purchases/") and create a separate exactEndpoints set (or check equality)
for singleton paths like "/version", "/health", "/api/health", "/api/info",
"/docs", "/api/docs", and other non-dynamic routes, then in the loop use exact
equality for exactEndpoints and strings.HasPrefix only for prefix-style entries
so fixed endpoints no longer bypass auth/CSRF.

---

Nitpick comments:
In `@internal/api/handler_purchases_test.go`:
- Around line 3434-3479: Add a new assertion in
TestHandler_revokePurchase_ExpiredToken to verify expiry takes precedence over
token equality: create a second call where the PurchaseExecution returned by
mockStore (from GetExecutionByID) has ApprovalTokenExpiresAt in the past but the
supplied query token is a non-matching value (e.g., wrongToken); call
handler.revokePurchase(ctx, reqWrongToken, execID, wrongToken) and assert the
error is a client error with code 409 and message contains "expired". Keep the
same mocks (mockStore, mockAuth/GetCloudAccountFn) and request headers as the
existing test so only the supplied token differs. Ensure you reference
handler.revokePurchase, TestHandler_revokePurchase_ExpiredToken,
GetExecutionByID and ApprovalTokenExpiresAt when locating where to add the new
case.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2d7a4e10-10de-4505-a161-499d3819237a

📥 Commits

Reviewing files that changed from the base of the PR and between 1be3f5c and bf1c8e3.

📒 Files selected for processing (15)
  • internal/api/coverage_gaps_test.go
  • internal/api/handler_purchases.go
  • internal/api/handler_purchases_test.go
  • internal/api/middleware.go
  • internal/api/router.go
  • internal/email/interfaces.go
  • internal/email/nop_sender.go
  • internal/email/sender.go
  • internal/email/smtp_sender.go
  • internal/email/templates.go
  • internal/mocks/email.go
  • internal/purchase/mocks_test.go
  • internal/scheduler/scheduler_test.go
  • internal/server/app_test.go
  • internal/server/handler_ri_exchange_test.go
💤 Files with no reviewable changes (10)
  • internal/scheduler/scheduler_test.go
  • internal/mocks/email.go
  • internal/purchase/mocks_test.go
  • internal/server/app_test.go
  • internal/server/handler_ri_exchange_test.go
  • internal/email/nop_sender.go
  • internal/email/sender.go
  • internal/email/templates.go
  • internal/email/smtp_sender.go
  • internal/email/interfaces.go
✅ Files skipped from review due to trivial changes (1)
  • internal/api/coverage_gaps_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • internal/api/router.go
  • internal/api/handler_purchases.go

@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 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.

@cristim

cristim commented Jun 4, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 4, 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.

@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.

@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: 2

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

17-34: ⚡ Quick win

Add explicit coverage for the new revoke public path.

The one-click revoke flow now depends on /api/purchases/revoke/<id> being treated as public by middleware, so this table should assert that contract alongside approve/cancel. A negative case like /api/purchases/revoked/... would also guard against another prefix-overlap regression.

Proposed test additions
 		{"/api/purchases/approve/12345678-1234-1234-1234-123456789abc", true},
 		{"/api/purchases/cancel/45645645-6456-4564-5645-645645645645", true},
+		{"/api/purchases/revoke/12345678-1234-1234-1234-123456789abc", true},
+		{"/api/purchases/revoked/12345678-1234-1234-1234-123456789abc", false},
 		{"/api/config", false},
🤖 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/middleware_test.go` around lines 17 - 34, Update the tests table
in middleware_test.go to cover the new public revoke path: add a positive case
for "/api/purchases/revoke/12345678-1234-1234-1234-123456789abc" (expected true)
alongside the existing approve/cancel entries, and add a negative prefix-overlap
case like "/api/purchases/revoked/12345678-1234-1234-1234-123456789abc"
(expected false) to ensure the middleware only exact-matches the revoke pattern;
modify the tests slice where paths and expected booleans are defined to include
these two entries.
🤖 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/middleware_test.go`:
- Around line 28-33: Run gofmt (or `go fmt`) on the test file to apply the
standard formatting changes reported by pre-commit and commit the reformatted
file; specifically reformat internal/api/middleware_test.go (the table of test
paths containing entries like "/version", "/version-evil", "/versionXYZ",
"/api/register", "/api/registrations") so the file matches gofmt output and CI
stops failing.

In `@internal/api/middleware.go`:
- Around line 42-48: The CSRF exemption logic is using strings.HasPrefix for
"/api/register" while isPublicEndpoint() was hardened to exact matches; update
requiresCSRFValidation() to use the same exact-match policy (e.g., compare path
== "/api/register" or delegate to isPublicEndpoint(path)) instead of
strings.HasPrefix so POST requests to "/api/register*" no longer improperly skip
CSRF validation; adjust any related checks to preserve the original
no-trailing-slash semantics for "/api/register".

---

Nitpick comments:
In `@internal/api/middleware_test.go`:
- Around line 17-34: Update the tests table in middleware_test.go to cover the
new public revoke path: add a positive case for
"/api/purchases/revoke/12345678-1234-1234-1234-123456789abc" (expected true)
alongside the existing approve/cancel entries, and add a negative prefix-overlap
case like "/api/purchases/revoked/12345678-1234-1234-1234-123456789abc"
(expected false) to ensure the middleware only exact-matches the revoke pattern;
modify the tests slice where paths and expected booleans are defined to include
these two entries.
🪄 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: 589913bd-170b-495a-90ff-a14877744b52

📥 Commits

Reviewing files that changed from the base of the PR and between bf1c8e3 and 15c934b.

📒 Files selected for processing (2)
  • internal/api/middleware.go
  • internal/api/middleware_test.go

Comment thread internal/api/middleware_test.go
Comment thread internal/api/middleware.go
cristim added 12 commits July 16, 2026 22:35
#291)

The post-execution notification (#291) had no send-path coverage: every
existing approve test built the handler with emailNotifier=nil, so the
send was silently skipped, and the direct-execute path had no test at all.

Add execution-flow tests with a recording emailNotifier asserting
SendPurchaseExecutedNotification fires exactly once with the expected To
(per-account contact), executor, and revocation token for all three
paths: token-approve, session-approve, and direct-execute. Include a
nil-notifier guard test confirming direct-execute still completes the
purchase without panicking when no notifier is configured.

Add internal/email render tests for RenderPurchaseExecutedNotification
Email / ...HTML and buildExecutedNotificationSubject: revoke URL present
with the execution route, token url-escaped (raw special chars never
leak), revoke panel omitted when the token is absent, the expiry note
gated on RevocationWindowClosesAt (deferred -> omitted; populated ->
rendered), and the single-vs-multi subject format.
internal/api test package failed to compile: TestHandler_getPurchaseDetails_
NotFound_IsClientError set a nonexistent Session.Role field. The Session
struct carries only UserID/Email; admin is resolved via HasPermissionAPI.
Drop the bogus field and confer admin through mockAuth.grantAdmin() so the
test reaches the GetExecutionByID not-found branch it asserts on (#431).

Also apply gofmt to handler_purchases_guards_test.go and middleware_test.go,
which carried multi-statement-per-line drift that failed the gofmt hook.
…leware_test

Replace strings.HasPrefix loop in requiresCSRFValidation() with a switch
statement using exact-match, mirroring the approach already in place for
isPublicEndpoint(). Previously a path like /api/register-malicious would
be silently exempted from CSRF validation because it shares the /api/register
prefix with the legitimate registration endpoint.

Add TestHandler_requiresCSRFValidation_ExactMatch to regression-guard the
prefix-overlap gap (e.g. POST /api/register-malicious must require CSRF).
The base branch added a req parameter to requiresCSRFValidation; the
ExactMatch test in this PR still called the 2-arg form. Pass nil for
tests that only exercise the path-matching branches (which return before
inspecting the request).
…mutates (CRITICAL: email prefetchers)

GET /api/purchases/revoke/:id now returns a minimal HTML confirmation page
containing a POST form with the token in a hidden input. No DB queries are
made on GET, so email prefetchers and link scanners cannot auto-trigger the
revocation.

The POST form encoding (application/x-www-form-urlencoded) is now also
supported by resolveApprovalToken alongside the existing JSON body and
query-string paths, so the confirmation form's submission is correctly
handled on the POST path.

Tests: TestRevokePurchase_GETRendersConfirmationPage_NoMutation,
       TestRevokePurchase_POSTPerformsRevoke
…leaked token cannot revoke later

After a successful ApproveExecution or CancelExecution, a follow-up
SavePurchaseExecution clears ApprovalToken and ApprovalTokenExpiresAt.
This prevents a token that leaked (e.g. via server logs or email
forwarding) from being replayed on the /revoke endpoint after the
primary action has already consumed it.

The rotation is best-effort: if the follow-up save fails the
approve/cancel has already landed, so only a warning is logged.

Tests: TestApproveExecution_RotatesApprovalToken,
       TestRevokePurchase_RejectsRotatedToken,
       TestRotateApprovalToken_PersistsEmpty
…oke reads token from body

Add redactURL / redactQueryParam helpers to middleware.go that replace any
token= query parameter value with REDACTED before it is written to a log
line. Apply to the debug log in HandleRequest and to logMissingCSRFToken.

resolveApprovalToken now also parses application/x-www-form-urlencoded
bodies (used by the GET /revoke confirmation form) so the token submitted
by the HTML form lands in the POST body rather than the query string.

Tests: TestRedactURL_StripsTokenQueryParam,
       TestRevokePOSTReadsTokenFromBody
…il copy

Add revocationWindowDuration = 24h const and enforce it in validateRevokeToken
via a new checkRevocationWindow helper. The reference timestamp is CompletedAt
(falling back to ExecutedAt for direct-execute rows); legacy rows without
either field skip the check for backward compatibility.

sendPurchaseExecutedEmail now populates RevocationWindowClosesAt so the email
body shows the deadline that validateRevokeToken enforces.

Tests: TestRevoke_WithinWindow_Allowed,
       TestRevoke_AfterWindow_Denied,
       TestRevoke_NoTimestamp_Allowed,
       TestRevoke_4EyesMode_TODO (skip placeholder for #1005)
…pdate clobber

Introduce SetCancelledBy as a targeted narrow SQL UPDATE (execution_id, cancelled_by)
instead of a full-row SavePurchaseExecution overwrite in revokeViaSession. The prior code
read the execution, mutated the struct, and wrote the whole row, which could clobber any
concurrent update (e.g. a CompletedAt write) that landed between the read and the write.

StoreInterface gains SetCancelledBy; PostgresStore, all mocks, and test stubs are updated.
Raw variable-length string comparison via subtle.ConstantTimeCompare leaks
token length through the short-circuit path in the Go stdlib implementation
when the two slices have different lengths. Pre-hashing both the stored and
user-supplied token to fixed-size 32-byte digests eliminates the length side
channel while preserving the constant-time property for equal-length inputs.

Applied to all three ApprovalToken comparison sites: loadCancelableExecution,
ApproveExecution (purchase package), validateRevokeToken (api package), and
the SQS worker path in messages.go.
lookupRequesterInfo silently swallowed both GetUser errors and nil-user
responses, making it impossible to distinguish a lookup failure from a
user that genuinely has no email. Add Warnf log lines on both branches
so operators can diagnose missing notification recipients.
…handler; fix mock sigs

After rebasing onto origin/main, several callers were broken by
signature changes that landed in main:

- TransitionExecutionStatus gained an actor *string parameter
  (PR #xxx): update all mock.On registrations in handler_purchases_test
  and approvals_test to pass the 4th/5th argument; use mock.MatchedBy
  for revoke-path callers where the actor is the revoker email.

- ApproveAndExecute gained a transitionedBy *string parameter: update
  executed_notification_flow_test mocks to pass (*string)(nil).

- Rename the PR-local revokeViaEmailToken handler (was revokePurchase,
  now collides with handler_purchases_revoke.go from main); update
  router registrations and all test call-sites accordingly.

- Wire sendPurchaseExecutedEmail in approveViaToken so the token-auth
  approve path fires the post-execution notification (the session-auth
  path already called it; this was the missing call).

- Extract validateApprovalToken helper from ApproveExecution to bring
  cyclomatic complexity below the gocyclo threshold.

All tests pass after these fixes.
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Rebased onto origin/main (post-#1208 merge, ed288c4). 2 struct conflicts resolved by intent: sender.go (kept main's NotificationData layout + added PR's new RevocationToken/ExecutedAt/ExecutedBy fields); interfaces.go (added PR's new SetCancelledBy method, kept main's CancelExecutionAtomic comment excluding scheduled status). Azure files no longer appear in diff (correctly inheriting from main now).

Gates:

  • build=0
  • vet=0
  • gocyclo=0 (touched files, threshold 10)
  • lintnew=0 (13 gocritic/misspell/govet warnings in new code, severity=warning, exit 0)
  • test=0 (2263 passed across internal/api, internal/email, internal/purchase)

Branch force-pushed: fix/291-wave2

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

@cristim Thanks for the rebase and conflict-resolution details. I’ll run an incremental review of the updated branch, with particular attention to the merged NotificationData and execution-cancellation interface contracts.

✅ 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.

Defect: approveViaToken called rotateApprovalToken which CLEARED
ApprovalToken in the DB, then passed the stale pre-approve execution
struct to sendPurchaseExecutedEmail. The email embedded the now-cleared
token as RevocationToken, causing validateRevokeToken to return 403 on
every revoke attempt from the executed-notification email.

Fix (Option A): after a successful token-authed approve, mintRevocationToken
generates a fresh random token with a 24-hour expiry (matching the
revocation window) and persists it. approveViaToken re-fetches the
execution after ApproveExecution returns so the email carries the valid
fresh token.

Security: approve-replay is blocked by the status check in
TransitionExecutionStatus (execution is "completed" after approve),
not by token rotation, so the fresh token cannot be used to re-approve.
The fresh token is scoped to revoke-only via checkRevokableStatus.

The cancel path is unchanged: clearApprovalToken (renamed from
rotateApprovalToken) still clears the token to empty since a canceled
execution has no valid follow-up action.

Nit #1: added CSRF validation to tryRevokeViaSession for consistency
with sibling cancelPurchaseViaSession and approvePurchaseViaSession.

Regression test: TestExecutedNotification_TokenApprovePath uses .Once()
to return different executions on the pre- and post-approve
GetExecutionByID calls and asserts the email RevocationToken matches
the fresh token AND passes validateRevokeToken. Confirmed FAILS on
pre-fix code (second .Once() unconsumed, token assertion fails) and
PASSES after fix.
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Blocking defect fixed (adversarial review)

Root cause: Two commits in this PR contradicted each other: rotateApprovalToken (commit 6b02807) cleared ApprovalToken to empty in the DB after a token-authed approve, but approveViaToken (commit ae80158) then called sendPurchaseExecutedEmail with the stale pre-approve execution struct, embedding the now-cleared token as RevocationToken. Every "Revoke" click from the executed-notification email returned 403 because validateRevokeToken checked the DB (empty) against the token the email claimed to carry (previously valid, now gone).

Fix chosen: Option A (preferred) over Option B.

Option A is cleaner because it preserves the security principle of token rotation while solving the email problem. The old approval token is replaced by a fresh revocation-scoped token (not just cleared), so:

  • The original approval email link can no longer be used for revocation (token hash changed)
  • The executed-notification email carries a valid, fresh token that works for the 24-hour window
  • Approve-replay is still blocked by the status check in TransitionExecutionStatus (completed cannot be approved), not dependent on token state

Changes

internal/purchase/approvals.go:

  • Added mintRevocationToken: generates a fresh 32-byte random token with 24h expiry, fetches and persists the post-finalize row
  • Renamed rotateApprovalToken -> clearApprovalToken: still used for cancel (empty is correct there)
  • ApproveExecution now calls mintRevocationToken instead of clearApprovalToken
  • CancelExecution calls clearApprovalToken (no change in behavior, just clearer name)

internal/api/handler_purchases.go:

  • approveViaToken: re-fetches the execution after ApproveExecution returns to pick up the fresh revocation token before calling sendPurchaseExecutedEmail
  • tryRevokeViaSession: added CSRF check before revokeViaSession (nit Update Deployment Model to Terraform #1) for consistency with cancelPurchaseViaSession and approvePurchaseViaSession

Regression test

TestExecutedNotification_TokenApprovePath (in executed_notification_flow_test.go) is the regression test. It uses .Once() to return different values on the first vs second GetExecutionByID call:

  • First call (initial load): returns pending execution with "valid-token"
  • Second call (re-fetch after approve): returns completed execution with "fresh-revoke-token"

It then asserts: (a) the email carries "fresh-revoke-token" and (b) validateRevokeToken(freshExec, freshToken) returns nil.

Confirmed: FAILS on pre-fix code (second .Once() unconsumed, token assertion fails), PASSES after fix.

New tests added

  • TestApproveExecution_MintsRevocationToken: verifies fresh non-empty token after approve
  • TestClearApprovalToken_PersistsEmpty: tests cancel path (empty token)
  • TestMintRevocationToken_PersistsFreshToken: unit test for the new function
  • TestRevokePurchase_RejectsClearedToken: empty stored token (cancel path)
  • TestRevokePurchase_RejectsRotatedToken: updated to reflect new semantics (old token rejected when fresh token is stored)

Gate exit codes

  • go build ./...: 0
  • go vet ./...: 0
  • go test ./internal/api/... ./internal/email/... ./internal/purchase/... ./internal/config/...: 2907 passed, 0 failed
  • gocyclo -over 10 on touched files: 0 (empty)
  • golangci-lint run --new-from-rev=origin/main ./...: 0 new issues

Out-of-scope nits (follow-up)

Nits #2-#4 from the adversarial review are not addressed here per instructions (skip the other nits, note as follow-ups).

@coderabbitai review

Fold in post-security-review nits for PR #889.

1. approveViaToken: the re-fetch-failure fallback reused the stale
   pre-approve execution struct, which after a successful mint carries
   the OLD approval token (already replaced in the DB). Emailing it would
   make the Revoke button 403 -- the original defect resurrected on the
   rare degraded path. Now send a COPY with ApprovalToken blanked so the
   email template's {{if .RevocationToken}} suppresses the Revoke panel
   entirely (no broken button), and never mutate the caller's struct.

2. Corrected the fallback log line to describe the new behavior
   (panel suppressed) rather than the inaccurate "sending without valid
   revocation token".

3. Fixed the mintRevocationToken doc comment: it claimed an
   "empty RevocationToken" fallback that no longer matches the mint logic;
   it now accurately describes both degraded paths (mint-write failure vs
   handler re-fetch failure). Updated the stale
   TestApproveExecution_RotatesApprovalToken comment above the renamed
   _MintsRevocationToken test.

4. Shared the 24h window as config.RevocationWindow, replacing the two
   in-sync-by-comment constants (purchase.revocationTokenWindow and
   api.revocationWindowDuration). Both packages already import config, so
   no new import cycle.

Regression test:
TestExecutedNotification_TokenApprovePath_RefetchFailureSuppressesPanel
drives the token-approve flow with a failing post-approve re-fetch and
asserts the email carries an EMPTY RevocationToken (panel suppressed) and
that the caller's struct is untouched. Confirmed FAILS on pre-fix code
("Should be empty, but was valid-token") and PASSES after the fix.
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Folded in the four review nits (commit 52b6393)

1 (blocking degraded path fixed)approveViaToken re-fetch-failure fallback no longer reuses the stale pre-approve struct. That struct still carried the OLD approval token (already replaced in the DB by mintRevocationToken), so emailing it would 403 every Revoke click — the original defect resurrected on the rare degraded path. Now it sends a copy with ApprovalToken blanked, so the template's {{if .RevocationToken}} suppresses the Revoke panel entirely (no broken button). The caller's struct is left untouched (blanking on a copy).

2 — Corrected the fallback log line: "suppressing Revoke panel (no valid token available)" instead of the inaccurate "sending without valid revocation token".

3 — Fixed the mintRevocationToken doc comment (it claimed an empty-token fallback that no longer matched the mint logic; now describes both degraded paths accurately) and updated the stale TestApproveExecution_RotatesApprovalToken comment above the renamed _MintsRevocationToken test.

4 — Shared the 24h window as config.RevocationWindow, replacing the two in-sync-by-comment constants (purchase.revocationTokenWindow + api.revocationWindowDuration). Both packages already import config, so no new import cycle.

Regression test

TestExecutedNotification_TokenApprovePath_RefetchFailureSuppressesPanel drives the token-approve flow with a failing post-approve re-fetch and asserts the email carries an empty RevocationToken (panel suppressed) and that the caller's struct is untouched.

  • Confirmed FAILS pre-fix: Should be empty, but was valid-token
  • PASSES post-fix

Gate exit codes

Gate Exit
go build ./... 0
go vet ./... 0
go test ./internal/api/... ./internal/purchase/... -run 'Revoke|Approve|ExecutedNotif' 0 (96 passed)
full go test ./internal/api/... ./internal/purchase/... ./internal/config/... ./internal/email/... 0 (2908 passed)
gocyclo -over 10 on touched files 0 (empty)
golangci-lint run --new-from-rev=origin/main ./... 0 (0 new; the 13 reported are pre-existing baseline issues in touched files, none from the new lines)

Not merging — leaving for self-merge on green.

The CI "Lint Code" job runs the FULL golangci-lint (not incremental), which
surfaced gocritic/govet/misspell/unparam findings the --new-from-rev check
missed. Fix all of them so `golangci-lint run ./...` is clean.

gocritic:
- tryRevokeViaSession: name the (any, bool, error) results; rename the inner
  csrf/revoke locals so the named returns are not shadowed.
- sendPurchaseExecutedEmail: range over index with a pointer instead of
  copying 312-byte RecommendationRecord values each iteration.
- lookupRequesterInfo: rename the return that shadowed the imported 'email'
  package.
- SetCancelledBy (store_postgres + mock): combine the repeated string param
  types.

govet shadow:
- revokeViaEmailToken + ApproveExecution: use distinct error variable names
  (statusErr, tokErr) instead of re-declaring/reassigning the outer err,
  avoiding both the shadow warning and gocritic sloppyReassign.

unparam:
- lookupRequesterInfo always returned "" for the name (api.User has no
  display-name field). Drop the dead second return and set the email's
  RequestedByName to "" at the call site until a name source lands.

misspell (US locale): behaviour->behavior, authorised->authorized,
cancelled->canceled in prose comments. The one comment that referenced the
British-spelled cancelled_by DB column is reworded to avoid the token while
staying accurate (the SetCancelledBy call directly below keeps the column
discoverable).

No behavior change; all api/purchase/config tests still pass.
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Cleared the full "Lint Code" CI findings (commit a7961a1)

The CI Lint job runs the FULL golangci-lint run ./... (not --new-from-rev), which surfaced findings the incremental check missed. All 13 original + 3 secondary findings are now fixed; golangci-lint run ./... reports 0 issues.

gocritic

  • tryRevokeViaSession: named the (result any, handled bool, err error) returns; renamed the inner csrf/revoke locals so they no longer shadow the named returns.
  • sendPurchaseExecutedEmail rangeValCopy: range over index with a pointer instead of copying the 312-byte RecommendationRecord each iteration.
  • lookupRequesterInfo importShadow: renamed the return that shadowed the imported email package.
  • SetCancelledBy (store_postgres + mock) paramTypeCombine: combined executionID, cancelledBy string.

govet shadow

  • revokeViaEmailToken and ApproveExecution: used distinct error var names (statusErr, tokErr) rather than reassigning the outer err — this avoids both the shadow warning and the gocritic sloppyReassign that a plain err = would have triggered (the two linters pull in opposite directions on := vs =).

unparam

  • lookupRequesterInfo always returned "" for the name (api.User has no display-name field). Dropped the dead second return and set RequestedByName: "" at the call site until a name source lands. This one surfaced only after the importShadow fix.

misspell (US locale): behaviour->behavior, authorised->authorized, cancelled->canceled in prose comments. The single comment that referenced the British-spelled cancelled_by DB column was reworded to avoid the token while staying accurate (the SetCancelledBy call directly below keeps the column discoverable) — no SQL identifier or column name was changed.

Gate exit codes (all 0)

Gate Exit
golangci-lint run ./... (FULL, root) 00 issues.
go build ./... 0
go vet ./... 0
gocyclo -over 10 <touched non-test .go files> 0 (empty)
go test ./internal/api/... ./internal/purchase/... 0
go test ./internal/config/... 0

No behavior change. Not merging — leaving for self-merge on green.

@cristim
cristim merged commit 597faea into main Jul 16, 2026
19 checks passed
@cristim
cristim deleted the fix/291-wave2 branch July 16, 2026 22:42
@cristim

cristim commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Merged to main (rebased onto current main, all CI green). post-execution email notification with a working one-click revoke link (mints a fresh purpose-built 24h revocation token on approve, preserving approve-replay protection); adversarial-reviewed SHIP.

cristim added a commit that referenced this pull request Jul 22, 2026
…parsing

AWS Lambda Function URLs base64-encode POST bodies whenever the
Content-Type isn't recognized as plain text, which is the case for the
one-click revoke confirmation form's application/x-www-form-urlencoded
POST added in #889. handleLambdaHTTPEvent never checked
request.IsBase64Encoded, so resolveApprovalToken received a base64 blob
instead of "token=...", and every revoke via the email link 401'd in
Lambda mode (the primary deploy mode). The HTTP/Fargate adapter was
unaffected since it builds its own request with IsBase64Encoded always
false.

Decode the body once, centrally, right after the raw event is
unmarshaled and before anything downstream (OIDC, static files, the API
router) reads it. A decode failure returns the same 400 response
already used for a malformed event instead of silently proceeding with
garbage.

Regression coverage: the base64-encoded form-body test drives the real
failing revoke scenario end-to-end. A plain-body companion test proves
the decode stays gated on IsBase64Encoded so an unconditional decode
can't corrupt plaintext bodies, and a malformed-base64 test proves the
handler fails closed with a clean 400 rather than panicking or
forwarding a garbage body.
cristim added a commit that referenced this pull request Jul 22, 2026
…parsing

AWS Lambda Function URLs base64-encode POST bodies whenever the
Content-Type isn't recognized as plain text, which is the case for the
one-click revoke confirmation form's application/x-www-form-urlencoded
POST added in #889. handleLambdaHTTPEvent never checked
request.IsBase64Encoded, so resolveApprovalToken received a base64 blob
instead of "token=...", and every revoke via the email link 401'd in
Lambda mode (the primary deploy mode). The HTTP/Fargate adapter was
unaffected since it builds its own request with IsBase64Encoded always
false.

Decode the body once, centrally, right after the raw event is
unmarshaled and before anything downstream (OIDC, static files, the API
router) reads it. A decode failure returns the same 400 response
already used for a malformed event instead of silently proceeding with
garbage.
cristim added a commit that referenced this pull request Jul 22, 2026
…parsing (#1483)

AWS Lambda Function URLs base64-encode POST bodies whenever the
Content-Type isn't recognized as plain text, which is the case for the
one-click revoke confirmation form's application/x-www-form-urlencoded
POST added in #889. handleLambdaHTTPEvent never checked
request.IsBase64Encoded, so resolveApprovalToken received a base64 blob
instead of "token=...", and every revoke via the email link 401'd in
Lambda mode (the primary deploy mode). The HTTP/Fargate adapter was
unaffected since it builds its own request with IsBase64Encoded always
false.

Decode the body once, centrally, right after the raw event is
unmarshaled and before anything downstream (OIDC, static files, the API
router) reads it. A decode failure returns the same 400 response
already used for a malformed event instead of silently proceeding with
garbage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/s Hours impact/many Affects most users priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/feat New capability urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant