Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions frontend/src/__tests__/purchase-execution-toast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -354,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', () => {
Expand Down Expand Up @@ -661,3 +693,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<boolean>((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');
});
});
7 changes: 7 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 32 additions & 13 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ async function handleExecutePurchase(): Promise<void> {
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.
Expand All @@ -313,7 +323,13 @@ async function handleExecutePurchase(): Promise<void> {
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
Expand Down Expand Up @@ -342,12 +358,6 @@ async function handleExecutePurchase(): Promise<void> {
? 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();
Expand Down Expand Up @@ -408,6 +418,15 @@ async function handleExecutePurchase(): Promise<void> {
* confirmDialog.
*/
async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
// 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.
Expand All @@ -417,12 +436,12 @@ async function handleFanOutExecute(buckets: FanOutBucket[]): Promise<void> {
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
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions internal/analytics/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
10 changes: 7 additions & 3 deletions internal/api/handler_per_account_perms_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -642,18 +644,20 @@ 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{}{
{
"id": "rec-a-exec",
"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.
},
},
})
Expand Down
Loading
Loading