From af8a9a576ace4413a157aeb7bc2a89756909be0b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 21 May 2026 14:53:43 +0200 Subject: [PATCH 1/3] fix(api/purchases): guard web purchase execution against malformed recs and double-submit Two related guards on the web executePurchase path: - #643: validate each client-supplied recommendation's Provider/Service/ Term/Payment/Count at the API boundary (validatePurchaseRecommendation in validation.go), wired into validateExecutePurchaseRequest after the account scope check. Per-provider Term (1/3) and Payment whitelists reject malformed values (Term:7, Payment:"foo", non-positive Count, empty/all provider) before they reach the cloud SDK with a silent default substituted. Scoped to the web execute path only; the retry path replays already-validated recs and is not re-gated. - #644: submit-time idempotency. purchaseIdempotencyKey hashes actor + sorted rec tuples + capacity; findDuplicatePendingExecution recomputes the key for recent web-sourced pending executions (no schema change) and collapses a double-click/retry within a 2-minute window onto the existing execution instead of minting a second approvable row (double-spend). The frontend execute button is now disabled before the confirm dialog and network call in both the single and fan-out paths, and re-enabled on cancel. Updated existing executePurchase / per-account-perms tests to carry valid recs and the new GetPendingExecutions lookup; added table-driven validation tests, idempotency-key and duplicate-lookup tests, and frontend button-disable tests. #647 (server-side capacity_percent consistency) requires plumbing the pre-scaling count through the frontend and rec type; split into a follow-up to keep this change focused. --- .../purchase-execution-toast.test.ts | 54 ++++ frontend/src/app.ts | 45 +++- .../api/handler_per_account_perms_test.go | 10 +- internal/api/handler_purchases.go | 147 ++++++++++- internal/api/handler_purchases_guards_test.go | 235 ++++++++++++++++++ internal/api/handler_purchases_test.go | 14 +- internal/api/validation.go | 68 +++++ 7 files changed, 551 insertions(+), 22 deletions(-) create mode 100644 internal/api/handler_purchases_guards_test.go diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index c5e778425..a447255bc 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -111,6 +111,7 @@ import * as api from '../api'; import * as recs from '../recommendations'; import * as plans from '../plans'; import * as archera from '../archera'; +import { confirmDialog } from '../confirmDialog'; // ── helpers ─────────────────────────────────────────────────────────────────── @@ -661,3 +662,56 @@ describe('handleFanOutExecute — fan-out path', () => { expect(submittedRecs[0]?.engine).toBe('postgres'); }); }); + +// #644: the execute button must be disabled BEFORE the confirm dialog / network +// call so a double-click can't fire a second POST (duplicate pending execution). +describe('handleExecutePurchase — double-submit guard (#644)', () => { + beforeEach(() => { + jest.clearAllMocks(); + (recs.getFanOutBuckets as jest.Mock).mockReturnValue([]); + (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([buildMinimalRec()]); + (plans.closePurchaseModal as jest.Mock).mockImplementation(() => undefined); + }); + + afterEach(() => { + document.body.textContent = ''; + }); + + test('button is disabled while the confirm dialog is pending (before any POST)', async () => { + let resolveConfirm: (v: boolean) => void = () => undefined; + (confirmDialog as jest.Mock).mockReturnValueOnce( + new Promise((resolve) => { + resolveConfirm = resolve; + }), + ); + (api.executePurchase as jest.Mock).mockResolvedValue({ + execution_id: 'exec-1', + status: 'pending', + email_sent: true, + }); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + // Confirm dialog is still open: button disabled, no POST yet. + expect(btn.disabled).toBe(true); + expect(api.executePurchase).not.toHaveBeenCalled(); + + resolveConfirm(true); + await new Promise((r) => setTimeout(r, 0)); + expect(api.executePurchase).toHaveBeenCalledTimes(1); + }); + + test('button is re-enabled when the user cancels the confirm dialog', async () => { + (confirmDialog as jest.Mock).mockResolvedValueOnce(false); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(api.executePurchase).not.toHaveBeenCalled(); + expect(btn.disabled).toBe(false); + expect(btn.textContent).toBe('Send for Approval'); + }); +}); diff --git a/frontend/src/app.ts b/frontend/src/app.ts index 5407b8b38..0401b237e 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -301,6 +301,16 @@ async function handleExecutePurchase(): Promise { return; } + // Disable the button BEFORE awaiting the confirm dialog and the network + // call so a double-click or rapid re-click can't fire a second POST and + // mint a duplicate pending execution (#644). The button is re-enabled on + // cancel below and in the finally block once the request settles. + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; + if (executeBtn) { + executeBtn.disabled = true; + executeBtn.textContent = 'Sending...'; + } + // Default approval-required path: clicking sends an approval request to // the configured approver(s) — it does NOT spend money. The actual // upfront charge fires only after an approver clicks the email link. @@ -313,7 +323,13 @@ async function handleExecutePurchase(): Promise { confirmLabel: 'Send for approval', destructive: false, }); - if (!ok) return; + if (!ok) { + if (executeBtn) { + executeBtn.disabled = false; + executeBtn.textContent = 'Send for Approval'; + } + return; + } // Build the POST body recs by spreading the server-provided rec so that // all fields (including `details`, `engine`, `cloud_account_id`, and any @@ -342,12 +358,6 @@ async function handleExecutePurchase(): Promise { ? Math.max(1, Math.min(100, parseInt(capacityInput.value, 10) || 100)) : 100; - const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; - if (executeBtn) { - executeBtn.disabled = true; - executeBtn.textContent = 'Sending...'; - } - try { const result = await api.executePurchase(apiRecs, capacityPercent); closePurchaseModal(); @@ -408,6 +418,15 @@ async function handleExecutePurchase(): Promise { * confirmDialog. */ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { + // Disable the button BEFORE the confirm dialog and the parallel POSTs so a + // double-click can't fan out a second wave of duplicate executions (#644). + // Re-enabled on cancel below and after the calls settle at the end. + const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; + if (executeBtn) { + executeBtn.disabled = true; + executeBtn.textContent = `Sending 0/${buckets.length}…`; + } + // Same approval-required default as the single-purchase path: each // bucket POSTs a request that triggers an approval email; the actual // charges fire when each approver clicks the link in their email. @@ -417,12 +436,12 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise { confirmLabel: 'Send all for approval', destructive: false, }); - if (!ok) return; - - const executeBtn = document.getElementById('execute-purchase-btn') as HTMLButtonElement | null; - if (executeBtn) { - executeBtn.disabled = true; - executeBtn.textContent = `Sending 0/${buckets.length}…`; + if (!ok) { + if (executeBtn) { + executeBtn.disabled = false; + executeBtn.textContent = 'Send for Approval'; + } + return; } // Fire all POSTs in parallel via allSettled so one failure doesn't diff --git a/internal/api/handler_per_account_perms_test.go b/internal/api/handler_per_account_perms_test.go index f641b58ff..84e1ad7ca 100644 --- a/internal/api/handler_per_account_perms_test.go +++ b/internal/api/handler_per_account_perms_test.go @@ -632,6 +632,8 @@ func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { mockStore := new(MockConfigStore) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(nil) + // #644 idempotency lookup: no prior pending row → proceed to create. + mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) mockStore.ListCloudAccountsFn = func(_ context.Context, _ config.CloudAccountFilter) ([]config.CloudAccount, error) { return permsAccountList(), nil } @@ -642,6 +644,8 @@ func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { } // Recommendation is tagged to account A — within the scoped user's allowed set. + // Carries a valid term/payment/count so the #643 per-rec validation passes; + // CreateSuppressionTx is a no-op in the mock when not explicitly expected. body, err := json.Marshal(map[string]interface{}{ "recommendations": []map[string]interface{}{ { @@ -649,11 +653,11 @@ func TestPerAccountPerms_ExecutePurchase_AllowedAccountAccepted(t *testing.T) { "provider": "aws", "service": "ec2", "cloud_account_id": permsAccA, + "count": 1, + "term": 1, + "payment": "all-upfront", "upfront_cost": 100.0, "savings": 10.0, - // count intentionally 0 so buildSuppressions skips the row and - // CreateSuppressionTx is never called — matches the pattern in - // TestHandler_executePurchase_Success. }, }, }) diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 65626050b..a5c5c3c3d 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -3,9 +3,12 @@ package api import ( "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" "fmt" + "sort" "strings" "time" @@ -1080,12 +1083,36 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event // account outside the session's allowed_accounts. Safer than silently // dropping the out-of-scope ones — the user explicitly chose those // recommendations; a partial execution would misrepresent intent. + // Runs before per-rec content validation so an out-of-scope request is + // rejected as 403 regardless of the rec's Term/Payment/Count contents. if err := h.validatePurchaseRecommendationScope(ctx, session, execReq.Recommendations); err != nil { return ExecutePurchaseRequest{}, nil, err } + // Per-rec Provider/Service/Term/Payment/Count validation at the API + // boundary so a malformed client-supplied rec (e.g. Term:7, Payment:"foo", + // negative Count) is rejected here rather than reaching the cloud SDK at + // execute time (#643). This is scoped to the web execute path only — the + // retry path replays recs from an already-validated execution and must + // not be re-gated by the same rules. + if err := validateExecutePurchaseRecommendations(execReq.Recommendations); err != nil { + return ExecutePurchaseRequest{}, nil, err + } return execReq, session, nil } +// validateExecutePurchaseRecommendations runs the per-rec #643 boundary +// validation over every rec in a web execute request, returning the first +// failure. Extracted so validateExecutePurchaseRequest stays under the +// gocyclo threshold. +func validateExecutePurchaseRecommendations(recs []config.RecommendationRecord) error { + for i := range recs { + if err := validatePurchaseRecommendation(recs[i], i); err != nil { + return err + } + } + return nil +} + // finalizePurchaseStatus flips an execution's stored status to "failed" if // the approval email couldn't send, and returns the status string the API // response should carry. Returns the original "pending" when email_sent is @@ -1168,6 +1195,115 @@ func (h *Handler) persistExecutionAndSuppressions(ctx context.Context, execution return nil } +// purchaseIdempotencyWindow is how long a freshly-created pending execution +// "absorbs" an identical resubmission. A double-click or a retried network +// call within this window resolves to the original execution instead of +// minting a second approvable row (double-spend). Sized to comfortably cover +// a stuck request retry without masking a genuine intentional re-purchase. +const purchaseIdempotencyWindow = 2 * time.Minute + +// purchaseIdempotencyKey derives a stable fingerprint of a submit so two +// identical submissions (same actor, same scaled rec set, same capacity) +// collapse to one execution. The recs are normalized and sorted so map/slice +// ordering can't change the hash. Account scope is implicit: each rec carries +// its CloudAccountID, so the same recs targeting a different account hash +// differently. Closes issue #644. +func purchaseIdempotencyKey(creatorID string, recs []config.RecommendationRecord, capacityPercent int) string { + tuples := make([]string, 0, len(recs)) + for _, r := range recs { + acct := "" + if r.CloudAccountID != nil { + acct = *r.CloudAccountID + } + tuples = append(tuples, fmt.Sprintf("%s|%s|%s|%s|%s|%s|%d|%d|%s", + strings.ToLower(strings.TrimSpace(r.Provider)), + r.Service, r.Region, r.ResourceType, r.Engine, acct, + r.Count, r.Term, strings.ToLower(strings.TrimSpace(r.Payment)))) + } + sort.Strings(tuples) + h := sha256.New() + fmt.Fprintf(h, "%s\x1f%d\x1f%s", creatorID, capacityPercent, strings.Join(tuples, "\x1e")) + return hex.EncodeToString(h.Sum(nil)) +} + +// findDuplicatePendingExecution returns an existing pending/notified execution +// whose idempotency fingerprint matches key and whose creation falls inside +// purchaseIdempotencyWindow of now, or (nil, nil) when there is no duplicate. +// It recomputes each candidate's key from its persisted recs + creator + +// capacity rather than relying on a stored column, so no schema change is +// needed. Restricted to web-sourced executions to avoid colliding with +// scheduler/CLI rows. A lookup error is non-fatal to the caller's decision +// (returned so the caller can log and proceed with a fresh execution). +func (h *Handler) findDuplicatePendingExecution(ctx context.Context, creatorID, key string, now time.Time) (*config.PurchaseExecution, error) { + pending, err := h.config.GetPendingExecutions(ctx) + if err != nil { + return nil, err + } + cutoff := now.Add(-purchaseIdempotencyWindow) + for i := range pending { + ex := &pending[i] + if ex.Source != common.PurchaseSourceWeb { + continue + } + if ex.ScheduledDate.Before(cutoff) { + continue + } + exCreator := "" + if ex.CreatedByUserID != nil { + exCreator = *ex.CreatedByUserID + } + if exCreator != creatorID { + continue + } + if purchaseIdempotencyKey(exCreator, ex.Recommendations, ex.CapacityPercent) == key { + return ex, nil + } + } + return nil, nil +} + +// duplicatePurchaseResponse returns a ready-to-send response body when this +// submit collapses onto an existing pending execution (#644), or nil when it +// is a genuinely new submit that should proceed to create a fresh execution. +// A lookup failure is logged and treated as "not a duplicate" so a transient +// store error never blocks a legitimate purchase. Extracted from executePurchase +// to keep that function under the gocyclo threshold. +func (h *Handler) duplicatePurchaseResponse(ctx context.Context, creator *string, recs []config.RecommendationRecord, capacityPercent int) map[string]any { + creatorID := "" + if creator != nil { + creatorID = *creator + } + key := purchaseIdempotencyKey(creatorID, recs, capacityPercent) + dup, err := h.findDuplicatePendingExecution(ctx, creatorID, key, time.Now()) + if err != nil { + logging.Errorf("idempotency lookup failed, proceeding with new execution: %v", err) + return nil + } + if dup == nil { + return nil + } + logging.Infof("duplicate purchase submit collapsed to existing execution %s", dup.ExecutionID) + return buildDuplicatePurchaseResponse(dup) +} + +// buildDuplicatePurchaseResponse returns the executePurchase response body for +// a submit that collapsed onto an existing pending execution (#644). It points +// at the original row so the client lands on the same approvable execution +// instead of a freshly-minted duplicate. duplicate=true lets the UI explain +// why no new approval email was sent. +func buildDuplicatePurchaseResponse(ex *config.PurchaseExecution) map[string]any { + return map[string]any{ + "execution_id": ex.ExecutionID, + "status": ex.Status, + "recommendation_count": len(ex.Recommendations), + "total_upfront_cost": ex.TotalUpfrontCost, + "estimated_savings": ex.EstimatedSavings, + "email_sent": ex.NotificationSent != nil, + "duplicate": true, + "message": "Duplicate submission collapsed onto the existing pending execution; no new approval request was created.", + } +} + // newPendingExecution builds a fresh pending PurchaseExecution with a // crypto/rand-backed approval token. Extracted from executePurchase to keep // that function under the gocyclo threshold. @@ -1202,6 +1338,15 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio return nil, err } + // Submit-time idempotency (#644): a double-click or retried POST with an + // identical actor + scaled rec set + capacity within a short window must + // resolve to the original pending execution rather than minting a second + // approvable row (double-spend). + creator := resolveCreatorUserID(session) + if dupResp := h.duplicatePurchaseResponse(ctx, creator, execReq.Recommendations, execReq.CapacityPercent); dupResp != nil { + return dupResp, nil + } + execution, err := newPendingExecution(&execReq, totalUpfront, totalSavings) if err != nil { return nil, err @@ -1211,7 +1356,7 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio // this stamp to identify the creator on later cancellation; legacy rows // pre-dating issue #46 can carry NULL here, so always go through the // resolveCreatorUserID helper rather than dereferencing the session. - execution.CreatedByUserID = resolveCreatorUserID(session) + execution.CreatedByUserID = creator executionID := execution.ExecutionID // Load the grace-period config once before entering the tx so a diff --git a/internal/api/handler_purchases_guards_test.go b/internal/api/handler_purchases_guards_test.go new file mode 100644 index 000000000..f9c89c979 --- /dev/null +++ b/internal/api/handler_purchases_guards_test.go @@ -0,0 +1,235 @@ +package api + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/CUDly/internal/config" + "github.com/LeanerCloud/CUDly/pkg/common" +) + +// strptr is a tiny local helper to take the address of a string literal. +func strptr(s string) *string { return &s } + +func validRec() config.RecommendationRecord { + return config.RecommendationRecord{ + ID: "rec-1", + Provider: "aws", + Service: "ec2", + Region: "us-east-1", + ResourceType: "t3.medium", + Count: 2, + Term: 3, + Payment: "all-upfront", + } +} + +// --- #643: per-rec Term/Payment/Count/Provider/Service validation --- + +func TestValidatePurchaseRecommendation(t *testing.T) { + t.Parallel() + mutate := func(f func(r *config.RecommendationRecord)) config.RecommendationRecord { + r := validRec() + f(&r) + return r + } + tests := []struct { + name string + rec config.RecommendationRecord + wantError bool + }{ + {"valid aws all-upfront 3y", validRec(), false}, + {"valid aws no-upfront 1y", mutate(func(r *config.RecommendationRecord) { r.Payment = "no-upfront"; r.Term = 1 }), false}, + {"valid azure monthly", mutate(func(r *config.RecommendationRecord) { r.Provider = "azure"; r.Payment = "monthly" }), false}, + {"valid gcp upfront", mutate(func(r *config.RecommendationRecord) { r.Provider = "gcp"; r.Payment = "upfront" }), false}, + {"payment case-insensitive", mutate(func(r *config.RecommendationRecord) { r.Payment = "All-Upfront" }), false}, + {"invalid term 7", mutate(func(r *config.RecommendationRecord) { r.Term = 7 }), true}, + {"invalid term 0", mutate(func(r *config.RecommendationRecord) { r.Term = 0 }), true}, + {"invalid payment foo", mutate(func(r *config.RecommendationRecord) { r.Payment = "foo" }), true}, + {"aws rejects azure-only monthly", mutate(func(r *config.RecommendationRecord) { r.Payment = "monthly" }), true}, + {"negative count", mutate(func(r *config.RecommendationRecord) { r.Count = -1 }), true}, + {"zero count", mutate(func(r *config.RecommendationRecord) { r.Count = 0 }), true}, + {"empty service", mutate(func(r *config.RecommendationRecord) { r.Service = "" }), true}, + {"empty provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "" }), true}, + {"all provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "all" }), true}, + {"unknown provider rejected", mutate(func(r *config.RecommendationRecord) { r.Provider = "ibm" }), true}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validatePurchaseRecommendation(tt.rec, 0) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} + +// The per-rec #643 validation is wired into the web execute boundary +// (validateExecutePurchaseRequest), NOT the shared validateAndTotalRecommendations +// which the retry path also calls with replayed recs. This test pins that +// separation: validateAndTotalRecommendations must still accept a zero-count rec +// so the retry path (which replays already-validated recs that may carry Count:0 +// shorthand) is not re-gated by the submit-time rules. +func TestValidateAndTotalRecommendations_DoesNotGateCount(t *testing.T) { + t.Parallel() + zero := validRec() + zero.Count = 0 + _, _, err := validateAndTotalRecommendations([]config.RecommendationRecord{zero}) + require.NoError(t, err) +} + +// --- #644: submit-time idempotency key + duplicate lookup --- + +func TestPurchaseIdempotencyKey_StableAndDiscriminating(t *testing.T) { + t.Parallel() + recsA := []config.RecommendationRecord{validRec()} + // Same content, different slice order must hash the same. + r2 := validRec() + r2.ID = "rec-2" + r2.Region = "eu-west-1" + ordered := []config.RecommendationRecord{validRec(), r2} + reordered := []config.RecommendationRecord{r2, validRec()} + + assert.Equal(t, + purchaseIdempotencyKey("user-1", recsA, 100), + purchaseIdempotencyKey("user-1", recsA, 100), + "identical input must hash identically") + assert.Equal(t, + purchaseIdempotencyKey("user-1", ordered, 100), + purchaseIdempotencyKey("user-1", reordered, 100), + "slice order must not change the key") + + // Discriminating dimensions. + assert.NotEqual(t, purchaseIdempotencyKey("user-1", recsA, 100), purchaseIdempotencyKey("user-2", recsA, 100), "creator") + assert.NotEqual(t, purchaseIdempotencyKey("user-1", recsA, 100), purchaseIdempotencyKey("user-1", recsA, 50), "capacity") + + scaled := []config.RecommendationRecord{validRec()} + scaled[0].Count = 1 + assert.NotEqual(t, purchaseIdempotencyKey("user-1", recsA, 100), purchaseIdempotencyKey("user-1", scaled, 100), "count") + + acctA := []config.RecommendationRecord{validRec()} + acctA[0].CloudAccountID = strptr("acct-A") + acctB := []config.RecommendationRecord{validRec()} + acctB[0].CloudAccountID = strptr("acct-B") + assert.NotEqual(t, purchaseIdempotencyKey("user-1", acctA, 100), purchaseIdempotencyKey("user-1", acctB, 100), "account") +} + +func TestFindDuplicatePendingExecution(t *testing.T) { + ctx := context.Background() + now := time.Now() + creator := "11111111-1111-1111-1111-111111111111" + recs := []config.RecommendationRecord{validRec()} + key := purchaseIdempotencyKey(creator, recs, 100) + + makeExec := func(id string, age time.Duration, src string, c *string, capacity int) config.PurchaseExecution { + // Copy recs so a subtest that mutates exec.Recommendations does not + // corrupt the shared slice used to compute `key` above. + recsCopy := append([]config.RecommendationRecord(nil), recs...) + return config.PurchaseExecution{ + ExecutionID: id, + Status: "pending", + Source: src, + ScheduledDate: now.Add(-age), + Recommendations: recsCopy, + CreatedByUserID: c, + CapacityPercent: capacity, + } + } + + t.Run("matching recent web execution is a duplicate", func(t *testing.T) { + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{ + makeExec("exec-dup", 30*time.Second, common.PurchaseSourceWeb, &creator, 100), + }, nil) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.NoError(t, err) + require.NotNil(t, dup) + assert.Equal(t, "exec-dup", dup.ExecutionID) + }) + + t.Run("outside the window is not a duplicate", func(t *testing.T) { + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{ + makeExec("exec-old", purchaseIdempotencyWindow+time.Minute, common.PurchaseSourceWeb, &creator, 100), + }, nil) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.NoError(t, err) + assert.Nil(t, dup) + }) + + t.Run("different creator is not a duplicate", func(t *testing.T) { + other := "22222222-2222-2222-2222-222222222222" + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{ + makeExec("exec-other", 10*time.Second, common.PurchaseSourceWeb, &other, 100), + }, nil) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.NoError(t, err) + assert.Nil(t, dup) + }) + + t.Run("non-web source is skipped", func(t *testing.T) { + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{ + makeExec("exec-cli", 10*time.Second, "cudly-cli", &creator, 100), + }, nil) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.NoError(t, err) + assert.Nil(t, dup) + }) + + t.Run("distinct rec set is not a duplicate", func(t *testing.T) { + differing := makeExec("exec-diff", 10*time.Second, common.PurchaseSourceWeb, &creator, 100) + differing.Recommendations[0].Count = 99 + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{differing}, nil) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.NoError(t, err) + assert.Nil(t, dup) + }) + + t.Run("lookup error is surfaced, not swallowed", func(t *testing.T) { + store := new(MockConfigStore) + store.On("GetPendingExecutions", ctx).Return(nil, errors.New("db down")) + h := &Handler{config: store} + dup, err := h.findDuplicatePendingExecution(ctx, creator, key, now) + require.Error(t, err) + assert.Nil(t, dup) + }) +} + +func TestBuildDuplicatePurchaseResponse(t *testing.T) { + t.Parallel() + sent := time.Now() + ex := &config.PurchaseExecution{ + ExecutionID: "exec-1", + Status: "pending", + Recommendations: []config.RecommendationRecord{validRec()}, + TotalUpfrontCost: 123.45, + EstimatedSavings: 67.89, + NotificationSent: &sent, + } + resp := buildDuplicatePurchaseResponse(ex) + assert.Equal(t, "exec-1", resp["execution_id"]) + assert.Equal(t, "pending", resp["status"]) + assert.Equal(t, 1, resp["recommendation_count"]) + assert.Equal(t, true, resp["duplicate"]) + assert.Equal(t, true, resp["email_sent"]) + + ex.NotificationSent = nil + assert.Equal(t, false, buildDuplicatePurchaseResponse(ex)["email_sent"]) +} diff --git a/internal/api/handler_purchases_test.go b/internal/api/handler_purchases_test.go index 14e1f5262..e43bd85ae 100644 --- a/internal/api/handler_purchases_test.go +++ b/internal/api/handler_purchases_test.go @@ -1274,6 +1274,9 @@ func TestHandler_executePurchase_Success(t *testing.T) { // window falls back to defaults and no suppression rows get // written (the recs in this request have no CloudAccountID). mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + // The #644 idempotency lookup queries pending executions before creating. + // No prior pending row → not a duplicate → proceeds to create. + mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1281,7 +1284,7 @@ func TestHandler_executePurchase_Success(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"recommendations": [{"id": "rec-1", "upfront_cost": 100.0, "savings": 50.0}, {"id": "rec-2", "upfront_cost": 200.0, "savings": 100.0}]}`, + Body: `{"recommendations": [{"id": "rec-1", "provider": "aws", "service": "ec2", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": 100.0, "savings": 50.0}, {"id": "rec-2", "provider": "aws", "service": "ec2", "count": 2, "term": 1, "payment": "all-upfront", "upfront_cost": 200.0, "savings": 100.0}]}`, } result, err := handler.executePurchase(ctx, req) require.NoError(t, err) @@ -1371,7 +1374,7 @@ func TestHandler_executePurchase_NegativeUpfrontCost(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"recommendations": [{"id": "rec-1", "upfront_cost": -100.0, "savings": 50.0}]}`, + Body: `{"recommendations": [{"id": "rec-1", "provider": "aws", "service": "ec2", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": -100.0, "savings": 50.0}]}`, } result, err := handler.executePurchase(ctx, req) assert.Error(t, err) @@ -1397,7 +1400,7 @@ func TestHandler_executePurchase_NegativeSavings(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"recommendations": [{"id": "rec-1", "upfront_cost": 100.0, "savings": -50.0}]}`, + Body: `{"recommendations": [{"id": "rec-1", "provider": "aws", "service": "ec2", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": 100.0, "savings": -50.0}]}`, } result, err := handler.executePurchase(ctx, req) assert.Error(t, err) @@ -1460,7 +1463,7 @@ func TestHandler_executePurchase_ExceedsMaxAmount(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"recommendations": [{"id": "rec-1", "upfront_cost": 15000000.0, "savings": 50.0}]}`, + Body: `{"recommendations": [{"id": "rec-1", "provider": "aws", "service": "ec2", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": 15000000.0, "savings": 50.0}]}`, } result, err := handler.executePurchase(ctx, req) assert.Error(t, err) @@ -1482,6 +1485,7 @@ func TestHandler_executePurchase_SaveError(t *testing.T) { mockAuth.On("ValidateSession", ctx, "admin-token").Return(adminSession, nil) mockStore.On("SavePurchaseExecution", ctx, mock.AnythingOfType("*config.PurchaseExecution")).Return(errors.New("database error")) mockStore.On("GetGlobalConfig", ctx).Return(&config.GlobalConfig{}, nil) + mockStore.On("GetPendingExecutions", ctx).Return([]config.PurchaseExecution{}, nil) handler := &Handler{config: mockStore, auth: mockAuth} @@ -1489,7 +1493,7 @@ func TestHandler_executePurchase_SaveError(t *testing.T) { Headers: map[string]string{ "Authorization": "Bearer admin-token", }, - Body: `{"recommendations": [{"id": "rec-1", "upfront_cost": 100.0, "savings": 50.0}]}`, + Body: `{"recommendations": [{"id": "rec-1", "provider": "aws", "service": "ec2", "count": 1, "term": 1, "payment": "all-upfront", "upfront_cost": 100.0, "savings": 50.0}]}`, } result, err := handler.executePurchase(ctx, req) assert.Error(t, err) diff --git a/internal/api/validation.go b/internal/api/validation.go index a402240d7..610cecd98 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -10,6 +10,8 @@ import ( "unicode" "github.com/aws/aws-lambda-go/events" + + "github.com/LeanerCloud/CUDly/internal/config" ) // Security constants @@ -461,6 +463,72 @@ func parseAccountIDs(raw string) ([]string, error) { return ids, nil } +// purchasePaymentWhitelist maps a concrete provider to the set of payment +// options that provider's purchase path accepts. AWS RIs/SPs support the +// three classic upfront tiers; Azure/GCP reservations additionally accept +// the "upfront"/"monthly" spellings their SDK clients switch on (see +// providers/{azure,gcp}/services/*/client.go). Any value outside the set +// for the rec's provider is rejected at the API boundary so a malformed +// Payment never reaches the cloud SDK with a silent default substituted +// (issue #643). +var purchasePaymentWhitelist = map[string]map[string]bool{ + "aws": { + "all-upfront": true, + "partial-upfront": true, + "no-upfront": true, + }, + "azure": { + "all-upfront": true, + "upfront": true, + "no-upfront": true, + "monthly": true, + }, + "gcp": { + "all-upfront": true, + "upfront": true, + "no-upfront": true, + "monthly": true, + }, +} + +// purchaseTermWhitelist maps a concrete provider to the set of commitment +// terms (in years) that provider accepts. All three clouds offer 1- and +// 3-year reservations. A rec carrying e.g. Term:7 is rejected before +// execution rather than failing opaquely deep in the provider call. +var purchaseTermWhitelist = map[string]map[int]bool{ + "aws": {1: true, 3: true}, + "azure": {1: true, 3: true}, + "gcp": {1: true, 3: true}, +} + +// validatePurchaseRecommendation validates a single client-supplied +// recommendation before it reaches the cloud purchase SDK. Unlike the +// query-time validateProvider (which permits ""/"all"), the execute path +// requires a concrete provider because each rec triggers a real provider +// call. idx is the rec's position in the request slice, surfaced in the +// error so the caller can point at the offending row. Closes issue #643. +func validatePurchaseRecommendation(rec config.RecommendationRecord, idx int) error { + provider := strings.ToLower(strings.TrimSpace(rec.Provider)) + payments, providerOK := purchasePaymentWhitelist[provider] + if !providerOK { + return NewClientError(400, fmt.Sprintf("recommendation %d has invalid provider %q: must be one of aws, azure, gcp", idx, rec.Provider)) + } + if strings.TrimSpace(rec.Service) == "" { + return NewClientError(400, fmt.Sprintf("recommendation %d is missing a service", idx)) + } + if rec.Count <= 0 { + return NewClientError(400, fmt.Sprintf("recommendation %d has non-positive count: %d", idx, rec.Count)) + } + if !purchaseTermWhitelist[provider][rec.Term] { + return NewClientError(400, fmt.Sprintf("recommendation %d has invalid term %d for provider %s: must be 1 or 3", idx, rec.Term, provider)) + } + payment := strings.ToLower(strings.TrimSpace(rec.Payment)) + if !payments[payment] { + return NewClientError(400, fmt.Sprintf("recommendation %d has invalid payment %q for provider %s", idx, rec.Payment, provider)) + } + return nil +} + // decodeBase64Password decodes a base64-encoded password. // Returns the decoded password or an error if decoding fails. // If the input is empty, returns empty string with no error. From bd77fef6272975ab719ebe05d7f8be5f773a3f8f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Thu, 21 May 2026 15:08:10 +0200 Subject: [PATCH 2/3] fix(api/purchases): validate capacity_percent against scaled rec counts (#647) capacity_percent was a decorative audit-only field: the frontend scaled rec counts client-side, sent the percent alongside, and the backend stored it without ever cross-checking that the scaled counts agreed with the recorded percent. The audit trail for a financial action could claim e.g. "50% capacity" while the recs summed to 100%. Fix: stamp the pre-scaling recommended count onto each rec at submit time (RecommendedCount in config.RecommendationRecord, recommended_count on the TS Recommendation/LocalRecommendation wire types) and add validateCapacityConsistency, wired into validateExecutePurchaseRequest after the per-rec content validation. It rejects any rec where floor(RecommendedCount*pct/100) != Count with a 400. The check is opt-in per rec: recs that carry no recommended_count (legacy callers, single-rec full-capacity purchases, retry replays) are skipped, so the change is backward-compatible and never produces a false rejection for callers that predate the field. JSONB persistence means no schema change. The frontend stamps recommended_count = r.count on the scaled copy in handleBulkPurchaseClick before the counts are floored, so the value the backend verifies always describes the recs it accompanies. Tests: table-driven validateCapacityConsistency cases (full/partial/floor/ mismatch/absent/mixed); a frontend round-trip test that recommended_count survives into the executePurchase POST body on the single-rec path. --- .../purchase-execution-toast.test.ts | 31 ++++++++++++++ frontend/src/api/types.ts | 7 ++++ frontend/src/recommendations.ts | 3 ++ frontend/src/types.ts | 6 +++ internal/api/handler_purchases.go | 32 +++++++++++---- internal/api/handler_purchases_guards_test.go | 41 +++++++++++++++++++ internal/api/validation.go | 25 +++++++++++ internal/config/types.go | 19 ++++++--- 8 files changed, 151 insertions(+), 13 deletions(-) diff --git a/frontend/src/__tests__/purchase-execution-toast.test.ts b/frontend/src/__tests__/purchase-execution-toast.test.ts index a447255bc..e90a8016f 100644 --- a/frontend/src/__tests__/purchase-execution-toast.test.ts +++ b/frontend/src/__tests__/purchase-execution-toast.test.ts @@ -355,6 +355,37 @@ describe('handleExecutePurchase — single-record path', () => { expect(submittedRecs[0]?.details).toEqual(rdsDetails); expect(submittedRecs[0]?.engine).toBe('postgres'); }); + + // Issue #647: a scaled rec carries recommended_count (the pre-scaling count) + // so the backend can verify capacity_percent against the scaled count. The + // single-rec submit path must forward it unchanged in the POST body. + test('#647 single-rec — recommended_count preserved in POST body', async () => { + (recs.getPurchaseModalRecommendations as jest.Mock).mockReturnValue([ + { + ...buildMinimalRec(), + count: 5, + recommended_count: 10, + }, + ]); + (api.executePurchase as jest.Mock).mockResolvedValue({ + execution_id: 'exec-647', + status: 'queued', + email_sent: true, + approval_recipient: 'approver@example.com', + }); + + const btn = setup(); + btn.click(); + await new Promise((r) => setTimeout(r, 0)); + + expect(api.executePurchase).toHaveBeenCalledTimes(1); + const [submittedRecs] = (api.executePurchase as jest.Mock).mock.calls[0] as [ + Array<{ count?: number; recommended_count?: number }>, + number, + ]; + expect(submittedRecs[0]?.count).toBe(5); + expect(submittedRecs[0]?.recommended_count).toBe(10); + }); }); describe('handleFanOutExecute — fan-out path', () => { diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f1c3a6ee3..9c92c8dae 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -98,6 +98,13 @@ export interface Recommendation { // those fall back to zero-valued defaults on the backend. See #597, #453. details?: unknown; count: number; + // recommended_count is the pre-scaling count this rec carried before the + // bulk-purchase Capacity % slider scaled it down. Stamped onto the scaled + // copy at submit time so the backend can verify capacity_percent against the + // scaled count rather than trusting a decorative audit field (#647). Absent + // on un-scaled / full-capacity / legacy recs, in which case the backend + // skips the consistency check for that rec. + recommended_count?: number; term: number; payment: string; upfront_cost: number; diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 8f977cb3c..b26750c9f 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -3118,6 +3118,9 @@ function handleBulkPurchaseClick(recommendations: LocalRecommendation[]): void { scaled.push({ ...r, count: newCount, + // Carry the pre-scaling count so the backend can verify the + // capacity_percent it records against the scaled count (#647). + recommended_count: r.count, upfront_cost: r.upfront_cost * ratio, monthly_cost: r.monthly_cost != null ? r.monthly_cost * ratio : null, savings: r.savings * ratio, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 793d47ff1..c0be7afe0 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -70,6 +70,12 @@ export interface LocalRecommendation { details?: unknown; region: string; count: number; + // recommended_count is the pre-scaling count this rec carried before the + // bulk-purchase Capacity % slider scaled it down. Stamped onto the scaled + // copy at purchase-submit time so the backend can verify capacity_percent + // against the scaled count instead of trusting a decorative audit field + // (#647). Absent on un-scaled recs (the rendered list) and on legacy rows. + recommended_count?: number; term: number; // The API stamps `payment` on every Recommendation row at collection // time, so runtime data carries it; surfacing it in the type lets the diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index a5c5c3c3d..8f3339103 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -1070,14 +1070,8 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event if len(execReq.Recommendations) > maxRecommendations { return ExecutePurchaseRequest{}, nil, NewClientError(400, fmt.Sprintf("too many recommendations: %d (max %d)", len(execReq.Recommendations), maxRecommendations)) } - // capacity_percent is audit-only but we still bound it: a request - // with 0 / absent → default 100; anything outside [1, 100] is a - // client bug worth surfacing rather than silently clamping. - if execReq.CapacityPercent == 0 { - execReq.CapacityPercent = 100 - } - if execReq.CapacityPercent < 1 || execReq.CapacityPercent > 100 { - return ExecutePurchaseRequest{}, nil, NewClientError(400, fmt.Sprintf("capacity_percent must be between 1 and 100, got %d", execReq.CapacityPercent)) + if err := normalizeCapacityPercent(&execReq); err != nil { + return ExecutePurchaseRequest{}, nil, err } // Scope: reject the whole request if any recommendation targets an // account outside the session's allowed_accounts. Safer than silently @@ -1097,9 +1091,31 @@ func (h *Handler) validateExecutePurchaseRequest(ctx context.Context, req *event if err := validateExecutePurchaseRecommendations(execReq.Recommendations); err != nil { return ExecutePurchaseRequest{}, nil, err } + // Cross-check the audit-only capacity_percent against the scaled rec + // counts so the persisted execution can't claim a capacity that + // disagrees with what was actually purchased (#647). Skipped per-rec + // when the rec carries no recommended_count. + if err := validateCapacityConsistency(execReq.Recommendations, execReq.CapacityPercent); err != nil { + return ExecutePurchaseRequest{}, nil, err + } return execReq, session, nil } +// normalizeCapacityPercent defaults an absent/zero capacity_percent to 100 +// and rejects anything outside [1, 100]. capacity_percent is audit-only but +// still bounded: a value outside the range is a client bug worth surfacing +// rather than silently clamping. Extracted so validateExecutePurchaseRequest +// stays under the gocyclo threshold. +func normalizeCapacityPercent(execReq *ExecutePurchaseRequest) error { + if execReq.CapacityPercent == 0 { + execReq.CapacityPercent = 100 + } + if execReq.CapacityPercent < 1 || execReq.CapacityPercent > 100 { + return NewClientError(400, fmt.Sprintf("capacity_percent must be between 1 and 100, got %d", execReq.CapacityPercent)) + } + return nil +} + // validateExecutePurchaseRecommendations runs the per-rec #643 boundary // validation over every rec in a web execute request, returning the first // failure. Extracted so validateExecutePurchaseRequest stays under the diff --git a/internal/api/handler_purchases_guards_test.go b/internal/api/handler_purchases_guards_test.go index f9c89c979..8f65964ef 100644 --- a/internal/api/handler_purchases_guards_test.go +++ b/internal/api/handler_purchases_guards_test.go @@ -233,3 +233,44 @@ func TestBuildDuplicatePurchaseResponse(t *testing.T) { ex.NotificationSent = nil assert.Equal(t, false, buildDuplicatePurchaseResponse(ex)["email_sent"]) } + +// --- #647: capacity_percent consistency with scaled rec counts --- + +func TestValidateCapacityConsistency(t *testing.T) { + t.Parallel() + // recWith builds a rec carrying both the scaled count and the pre-scaling + // recommended count so the cross-check has something to verify. + recWith := func(count, recommended int) config.RecommendationRecord { + r := validRec() + r.Count = count + r.RecommendedCount = recommended + return r + } + tests := []struct { + name string + recs []config.RecommendationRecord + capacity int + wantError bool + }{ + {"full capacity matches", []config.RecommendationRecord{recWith(10, 10)}, 100, false}, + {"50 percent floors to match", []config.RecommendationRecord{recWith(5, 10)}, 50, false}, + {"50 percent of odd floors down", []config.RecommendationRecord{recWith(5, 11)}, 50, false}, // floor(11*50/100)=5 + {"mismatch claims 50 but sent full", []config.RecommendationRecord{recWith(10, 10)}, 50, true}, + {"mismatch claims full but scaled", []config.RecommendationRecord{recWith(5, 10)}, 100, true}, + {"absent recommended_count is skipped", []config.RecommendationRecord{recWith(5, 0)}, 50, false}, + {"one consistent one inconsistent rejects", []config.RecommendationRecord{recWith(5, 10), recWith(10, 10)}, 50, true}, + {"empty recs ok", nil, 100, false}, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateCapacityConsistency(tt.recs, tt.capacity) + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/api/validation.go b/internal/api/validation.go index 610cecd98..cc8db2c60 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -529,6 +529,31 @@ func validatePurchaseRecommendation(rec config.RecommendationRecord, idx int) er return nil } +// validateCapacityConsistency cross-checks the client-supplied capacity_percent +// against the scaled rec counts so the audit record can't claim a capacity that +// disagrees with what was actually purchased (#647). The frontend scales each +// rec as floor(RecommendedCount * pct / 100); this recomputes that and rejects +// any rec where the scaled Count doesn't match. Recs that don't carry a +// RecommendedCount (0 / absent: legacy callers, single-rec full-capacity +// purchases, retry replays) are skipped — the field is opt-in, so its absence +// means "no claim to verify" rather than a failure. capacityPercent is the +// already-defaulted/bounded value (1..100) from validateExecutePurchaseRequest. +func validateCapacityConsistency(recs []config.RecommendationRecord, capacityPercent int) error { + for i := range recs { + rec := recs[i] + if rec.RecommendedCount <= 0 { + continue + } + expected := rec.RecommendedCount * capacityPercent / 100 + if expected != rec.Count { + return NewClientError(400, fmt.Sprintf( + "recommendation %d: count %d is inconsistent with capacity_percent %d%% of recommended_count %d (expected %d)", + i, rec.Count, capacityPercent, rec.RecommendedCount, expected)) + } + } + return nil +} + // decodeBase64Password decodes a base64-encoded password. // Returns the decoded password or an error if decoding fails. // If the input is empty, returns empty string with no error. diff --git a/internal/config/types.go b/internal/config/types.go index 66edb632d..a3f611409 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -292,11 +292,20 @@ type RecommendationRecord struct { // Platform / Tenancy / Scope / AZConfig values). New rows always // carry the full Details, so non-default platforms (Windows EC2, // Postgres RDS, etc.) round-trip correctly. - Details json.RawMessage `json:"details,omitempty" dynamodbav:"-"` - Count int `json:"count" dynamodbav:"count"` - Term int `json:"term" dynamodbav:"term"` - Payment string `json:"payment" dynamodbav:"payment"` - UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` + Details json.RawMessage `json:"details,omitempty" dynamodbav:"-"` + Count int `json:"count" dynamodbav:"count"` + // RecommendedCount is the pre-scaling count the collector originally + // recommended, before the bulk-purchase Capacity % slider scaled it down. + // The web execute path stamps it so the backend can verify the + // client-supplied capacity_percent against the scaled Count + // (floor(RecommendedCount*pct/100) must equal Count) rather than trusting + // a decorative audit field that could silently disagree (#647). Optional: + // 0 / absent means "not supplied" (legacy callers, scheduler/CLI rows, + // retry replays) and the consistency check is skipped for that rec. + RecommendedCount int `json:"recommended_count,omitempty" dynamodbav:"recommended_count,omitempty"` + Term int `json:"term" dynamodbav:"term"` + Payment string `json:"payment" dynamodbav:"payment"` + UpfrontCost float64 `json:"upfront_cost" dynamodbav:"upfront_cost"` // MonthlyCost is nil when the provider API did not return a monthly // recurring breakdown (rendered as "—" in the UI, not "$0"). // Backward-compatible with DynamoDB: existing items with a numeric 0 From d2490968340ae0b35613ff26e13ebd395e8a01dd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 22 May 2026 12:53:39 +0200 Subject: [PATCH 3/3] fix(api/purchases): atomic duplicate-detection via SELECT FOR UPDATE in tx (#643) Move the pending-execution scan inside the WithTx block so the duplicate check and the INSERT are a single atomic operation, closing the TOCTOU race that the pre-tx duplicatePurchaseResponse call could not prevent. - Add GetPendingExecutionsTx to StoreInterface (SELECT ... FOR UPDATE) - Extract scanExecutionRows helper to share row-scan logic between the pool path and the new tx path - Extract matchDuplicateInList to keep persistExecutionAndSuppressions and executePurchase under the gocyclo threshold - Add derefStringOrEmpty to eliminate the nil-check branch in executePurchase - Change validatePurchaseRecommendation to *RecommendationRecord receiver so provider/service/payment normalisation is written back to the caller - Add GetPendingExecutionsTx fallback to all six test mock types Closes #643 --- internal/analytics/collector_test.go | 3 + internal/api/handler_purchases.go | 93 ++++++++++++++++--- internal/api/handler_purchases_guards_test.go | 3 +- internal/api/mocks_test.go | 14 +++ internal/api/validation.go | 7 +- internal/config/interfaces.go | 6 ++ internal/config/store_postgres.go | 33 +++++++ internal/mocks/stores.go | 15 +++ internal/purchase/mocks_test.go | 3 + internal/scheduler/scheduler_test.go | 3 + internal/server/test_helpers_test.go | 3 + 11 files changed, 165 insertions(+), 18 deletions(-) diff --git a/internal/analytics/collector_test.go b/internal/analytics/collector_test.go index f9d362e24..2703b05b3 100644 --- a/internal/analytics/collector_test.go +++ b/internal/analytics/collector_test.go @@ -885,4 +885,7 @@ func (m *mockConfigStore) ListActiveSuppressions(_ context.Context) ([]config.Pu func (m *mockConfigStore) SavePurchaseExecutionTx(ctx context.Context, _ pgx.Tx, e *config.PurchaseExecution) error { return m.SavePurchaseExecution(ctx, e) } +func (m *mockConfigStore) GetPendingExecutionsTx(ctx context.Context, _ pgx.Tx) ([]config.PurchaseExecution, error) { + return m.GetPendingExecutions(ctx) +} func (m *mockConfigStore) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) } diff --git a/internal/api/handler_purchases.go b/internal/api/handler_purchases.go index 8f3339103..60ee9498d 100644 --- a/internal/api/handler_purchases.go +++ b/internal/api/handler_purchases.go @@ -1122,7 +1122,7 @@ func normalizeCapacityPercent(execReq *ExecutePurchaseRequest) error { // gocyclo threshold. func validateExecutePurchaseRecommendations(recs []config.RecommendationRecord) error { for i := range recs { - if err := validatePurchaseRecommendation(recs[i], i); err != nil { + if err := validatePurchaseRecommendation(&recs[i], i); err != nil { return err } } @@ -1169,6 +1169,16 @@ func validateAndTotalRecommendations(recs []config.RecommendationRecord) (upfron return upfront, savings, nil } +// derefStringOrEmpty returns the string a pointer points to, or "" when nil. +// Used to convert *string creator IDs to the bare string needed for hashing +// without adding a branch to the caller. +func derefStringOrEmpty(s *string) string { + if s != nil { + return *s + } + return "" +} + // resolveCreatorUserID returns a pointer to the session user's UUID for // stamping onto purchase_executions.created_by_user_id, or nil for // non-user sessions whose UserID isn't a real UUID. This keeps the @@ -1191,11 +1201,57 @@ func resolveCreatorUserID(session *Session) *string { } // executePurchase handles direct purchase execution from recommendations +// matchDuplicateInList scans a slice of pending executions for one that +// matches creatorID + idempotencyKey within the idempotency window. +// Returns the first match, or nil when there is no duplicate. +// Extracted from persistExecutionAndSuppressions to keep cyclomatic +// complexity under the project gocyclo threshold. +func matchDuplicateInList(pending []config.PurchaseExecution, creatorID, idempotencyKey string, now time.Time) *config.PurchaseExecution { + cutoff := now.Add(-purchaseIdempotencyWindow) + for i := range pending { + ex := &pending[i] + if ex.Source != common.PurchaseSourceWeb || ex.ScheduledDate.Before(cutoff) { + continue + } + exCreator := "" + if ex.CreatedByUserID != nil { + exCreator = *ex.CreatedByUserID + } + if exCreator == creatorID && purchaseIdempotencyKey(exCreator, ex.Recommendations, ex.CapacityPercent) == idempotencyKey { + return ex + } + } + return nil +} + // persistExecutionAndSuppressions saves the execution + its suppression -// records in a single transaction. Extracted from executePurchase to keep -// that function under the gocyclo threshold. -func (h *Handler) persistExecutionAndSuppressions(ctx context.Context, execution *config.PurchaseExecution, suppressions []config.PurchaseSuppression) error { - if err := h.config.WithTx(ctx, func(tx pgx.Tx) error { +// records in a single transaction. It also performs the duplicate-execution +// check inside the same transaction (using SELECT ... FOR UPDATE) so that the +// read and the insert are atomic — closing the TOCTOU race that the pre-tx +// duplicatePurchaseResponse call could not prevent (#643). +// +// Return values: +// - (nil, nil) — no duplicate found; execution was inserted. +// - (existing, nil) — duplicate found; execution was NOT inserted; caller +// should collapse onto existing. +// - (nil, err) — store error; caller should surface it. +func (h *Handler) persistExecutionAndSuppressions( + ctx context.Context, + execution *config.PurchaseExecution, + suppressions []config.PurchaseSuppression, + creatorID, idempotencyKey string, +) (dup *config.PurchaseExecution, err error) { + txErr := h.config.WithTx(ctx, func(tx pgx.Tx) error { + // Duplicate check inside the tx (SELECT FOR UPDATE) — atomic with + // the insert below. + pending, err := h.config.GetPendingExecutionsTx(ctx, tx) + if err != nil { + return err + } + if dup = matchDuplicateInList(pending, creatorID, idempotencyKey, time.Now()); dup != nil { + return nil // found duplicate — skip insert, commit tx (read-only) + } + if err := h.config.SavePurchaseExecutionTx(ctx, tx, execution); err != nil { return err } @@ -1205,10 +1261,11 @@ func (h *Handler) persistExecutionAndSuppressions(ctx context.Context, execution } } return nil - }); err != nil { - return fmt.Errorf("failed to save execution: %w", err) + }) + if txErr != nil { + return nil, fmt.Errorf("failed to save execution: %w", txErr) } - return nil + return dup, nil } // purchaseIdempotencyWindow is how long a freshly-created pending execution @@ -1354,14 +1411,15 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio return nil, err } - // Submit-time idempotency (#644): a double-click or retried POST with an - // identical actor + scaled rec set + capacity within a short window must + // Submit-time idempotency (#644/#643): a double-click or retried POST with + // an identical actor + scaled rec set + capacity within a short window must // resolve to the original pending execution rather than minting a second - // approvable row (double-spend). + // approvable row (double-spend). The atomic guard lives inside + // persistExecutionAndSuppressions (SELECT FOR UPDATE + INSERT in one tx) + // which closes the TOCTOU race that a pre-tx read alone cannot prevent. creator := resolveCreatorUserID(session) - if dupResp := h.duplicatePurchaseResponse(ctx, creator, execReq.Recommendations, execReq.CapacityPercent); dupResp != nil { - return dupResp, nil - } + creatorID := derefStringOrEmpty(creator) + idempotencyKey := purchaseIdempotencyKey(creatorID, execReq.Recommendations, execReq.CapacityPercent) execution, err := newPendingExecution(&execReq, totalUpfront, totalSavings) if err != nil { @@ -1385,9 +1443,14 @@ func (h *Handler) executePurchase(ctx context.Context, req *events.LambdaFunctio } suppressions := buildSuppressions(execReq.Recommendations, executionID, gracePeriodCfg, time.Now()) - if err := h.persistExecutionAndSuppressions(ctx, execution, suppressions); err != nil { + dupExec, err := h.persistExecutionAndSuppressions(ctx, execution, suppressions, creatorID, idempotencyKey) + if err != nil { return nil, err } + if dupExec != nil { + logging.Infof("concurrent duplicate purchase submit collapsed to existing execution %s", dupExec.ExecutionID) + return buildDuplicatePurchaseResponse(dupExec), nil + } // Send approval email synchronously so the response can surface the // actual outcome. The DB write above is the source of truth — email is diff --git a/internal/api/handler_purchases_guards_test.go b/internal/api/handler_purchases_guards_test.go index 8f65964ef..ddd9c10c5 100644 --- a/internal/api/handler_purchases_guards_test.go +++ b/internal/api/handler_purchases_guards_test.go @@ -63,7 +63,8 @@ func TestValidatePurchaseRecommendation(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - err := validatePurchaseRecommendation(tt.rec, 0) + rec := tt.rec + err := validatePurchaseRecommendation(&rec, 0) if tt.wantError { require.Error(t, err) } else { diff --git a/internal/api/mocks_test.go b/internal/api/mocks_test.go index 388aa3f59..e0c0956cb 100644 --- a/internal/api/mocks_test.go +++ b/internal/api/mocks_test.go @@ -558,6 +558,20 @@ func (m *MockConfigStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx return args.Error(0) } +// GetPendingExecutionsTx falls back to GetPendingExecutions when no explicit +// expectation is registered so existing tests that run the WithTx path still +// see the same pending-execution list without needing to set up a new mock. +func (m *MockConfigStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]config.PurchaseExecution, error) { + if !m.isExpected("GetPendingExecutionsTx") { + return m.GetPendingExecutions(ctx) + } + args := m.Called(ctx, tx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]config.PurchaseExecution), args.Error(1) +} + // WithTx invokes fn with a sentinel nil tx so callers get to exercise // their full tx callback (saving execution, creating suppressions, etc.) // and tests assert on the individual *Tx mock methods rather than on diff --git a/internal/api/validation.go b/internal/api/validation.go index cc8db2c60..92a41af84 100644 --- a/internal/api/validation.go +++ b/internal/api/validation.go @@ -507,13 +507,15 @@ var purchaseTermWhitelist = map[string]map[int]bool{ // requires a concrete provider because each rec triggers a real provider // call. idx is the rec's position in the request slice, surfaced in the // error so the caller can point at the offending row. Closes issue #643. -func validatePurchaseRecommendation(rec config.RecommendationRecord, idx int) error { +func validatePurchaseRecommendation(rec *config.RecommendationRecord, idx int) error { provider := strings.ToLower(strings.TrimSpace(rec.Provider)) payments, providerOK := purchasePaymentWhitelist[provider] if !providerOK { return NewClientError(400, fmt.Sprintf("recommendation %d has invalid provider %q: must be one of aws, azure, gcp", idx, rec.Provider)) } - if strings.TrimSpace(rec.Service) == "" { + rec.Provider = provider + rec.Service = strings.TrimSpace(rec.Service) + if rec.Service == "" { return NewClientError(400, fmt.Sprintf("recommendation %d is missing a service", idx)) } if rec.Count <= 0 { @@ -526,6 +528,7 @@ func validatePurchaseRecommendation(rec config.RecommendationRecord, idx int) er if !payments[payment] { return NewClientError(400, fmt.Sprintf("recommendation %d has invalid payment %q for provider %s", idx, rec.Payment, provider)) } + rec.Payment = payment return nil } diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index d364def93..0d214cec0 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -180,6 +180,12 @@ type StoreInterface interface { // so the execution insert + suppression writes commit atomically. SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *PurchaseExecution) error + // GetPendingExecutionsTx is the tx-accepting variant of + // GetPendingExecutions. Used inside the executePurchase WithTx block + // so the duplicate-detection read and the new-execution insert are + // atomic under the same transaction, eliminating the TOCTOU race (#643). + GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error) + // WithTx opens a pgx transaction, runs fn, and commits on success or // rolls back on error. fn can call any *Tx method on the store to // participate in the transaction. Nested transactions are not diff --git a/internal/config/store_postgres.go b/internal/config/store_postgres.go index f97aec921..eddfccd6c 100644 --- a/internal/config/store_postgres.go +++ b/internal/config/store_postgres.go @@ -872,6 +872,33 @@ func (s *PostgresStore) GetPendingExecutions(ctx context.Context) ([]PurchaseExe return s.queryExecutions(ctx, query) } +// GetPendingExecutionsTx is the tx-accepting variant of GetPendingExecutions. +// Running the read inside the same transaction as the subsequent insert makes +// duplicate-detection and execution creation atomic, closing the TOCTOU race +// in executePurchase (issue #643). +func (s *PostgresStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]PurchaseExecution, error) { + const query = ` + SELECT plan_id, execution_id, status, step_number, scheduled_date, + notification_sent, approval_token, recommendations, + total_upfront_cost, estimated_savings, completed_at, error, expires_at, + cloud_account_id, source, approved_by, cancelled_by, capacity_percent, + created_by_user_id, retry_execution_id, retry_attempt_n, + approval_token_expires_at + FROM purchase_executions + WHERE status IN ('pending', 'notified') + AND (expires_at IS NULL OR expires_at > NOW()) + ORDER BY scheduled_date ASC + LIMIT 1000 + FOR UPDATE + ` + rows, err := tx.Query(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to query pending executions in tx: %w", err) + } + defer rows.Close() + return scanExecutionRows(rows) +} + // GetExecutionByID retrieves a purchase execution by execution ID func (s *PostgresStore) GetExecutionByID(ctx context.Context, executionID string) (*PurchaseExecution, error) { query := ` @@ -978,7 +1005,13 @@ func (s *PostgresStore) queryExecutions(ctx context.Context, query string, args return nil, fmt.Errorf("failed to query executions: %w", err) } defer rows.Close() + return scanExecutionRows(rows) +} +// scanExecutionRows scans a pgx.Rows cursor into a slice of PurchaseExecution. +// It is used by queryExecutions (pool query) and GetPendingExecutionsTx (tx +// query) so the scan logic lives in one place. +func scanExecutionRows(rows pgx.Rows) ([]PurchaseExecution, error) { executions := make([]PurchaseExecution, 0) for rows.Next() { var exec PurchaseExecution diff --git a/internal/mocks/stores.go b/internal/mocks/stores.go index 82b5fe34b..1e8cc25de 100644 --- a/internal/mocks/stores.go +++ b/internal/mocks/stores.go @@ -696,6 +696,21 @@ func (m *MockConfigStore) ListActiveSuppressions(ctx context.Context) ([]config. return args.Get(0).([]config.PurchaseSuppression), args.Error(1) } +// GetPendingExecutionsTx mocks the GetPendingExecutionsTx operation. +// Falls back to GetPendingExecutions when no explicit expectation is registered +// (same pattern as SavePurchaseExecutionTx) so existing tests that exercise +// the WithTx path transparently get the same pending-execution list. +func (m *MockConfigStore) GetPendingExecutionsTx(ctx context.Context, tx pgx.Tx) ([]config.PurchaseExecution, error) { + if !isExpected(&m.Mock, "GetPendingExecutionsTx") { + return m.GetPendingExecutions(ctx) + } + args := m.Called(ctx, tx) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).([]config.PurchaseExecution), args.Error(1) +} + func (m *MockConfigStore) SavePurchaseExecutionTx(ctx context.Context, tx pgx.Tx, execution *config.PurchaseExecution) error { if !isExpected(&m.Mock, "SavePurchaseExecutionTx") { return m.SavePurchaseExecution(ctx, execution) diff --git a/internal/purchase/mocks_test.go b/internal/purchase/mocks_test.go index 8db797715..96b0163d8 100644 --- a/internal/purchase/mocks_test.go +++ b/internal/purchase/mocks_test.go @@ -519,6 +519,9 @@ func (m *MockConfigStore) SavePurchaseExecutionTx(ctx context.Context, _ pgx.Tx, // SavePurchaseExecution still see the write. return m.SavePurchaseExecution(ctx, exec) } +func (m *MockConfigStore) GetPendingExecutionsTx(ctx context.Context, _ pgx.Tx) ([]config.PurchaseExecution, error) { + return m.GetPendingExecutions(ctx) +} func (m *MockConfigStore) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) } diff --git a/internal/scheduler/scheduler_test.go b/internal/scheduler/scheduler_test.go index b8cca1299..051bb83cd 100644 --- a/internal/scheduler/scheduler_test.go +++ b/internal/scheduler/scheduler_test.go @@ -1932,6 +1932,9 @@ func (m *MockConfigStore) ListActiveSuppressions(_ context.Context) ([]config.Pu func (m *MockConfigStore) SavePurchaseExecutionTx(ctx context.Context, _ pgx.Tx, e *config.PurchaseExecution) error { return m.SavePurchaseExecution(ctx, e) } +func (m *MockConfigStore) GetPendingExecutionsTx(ctx context.Context, _ pgx.Tx) ([]config.PurchaseExecution, error) { + return m.GetPendingExecutions(ctx) +} func (m *MockConfigStore) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) } // fakeSTSClient is a minimal in-test STSClient implementation used by the diff --git a/internal/server/test_helpers_test.go b/internal/server/test_helpers_test.go index e817d98ff..958d9dd3f 100644 --- a/internal/server/test_helpers_test.go +++ b/internal/server/test_helpers_test.go @@ -258,6 +258,9 @@ func (m *mockConfigStoreForHealth) ListActiveSuppressions(_ context.Context) ([] func (m *mockConfigStoreForHealth) SavePurchaseExecutionTx(ctx context.Context, _ pgx.Tx, e *config.PurchaseExecution) error { return m.SavePurchaseExecution(ctx, e) } +func (m *mockConfigStoreForHealth) GetPendingExecutionsTx(ctx context.Context, _ pgx.Tx) ([]config.PurchaseExecution, error) { + return m.GetPendingExecutions(ctx) +} func (m *mockConfigStoreForHealth) WithTx(_ context.Context, fn func(tx pgx.Tx) error) error { return fn(nil) }