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
181 changes: 181 additions & 0 deletions frontend/src/__tests__/execute-mode-toggle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* Execute-mode toggle tests (issue #289).
*
* Verifies that the purchase modal shows the direct-execute toggle only
* when the session holds execute-any:purchases or execute-own:purchases,
* and that the toggle is absent (not rendered) for sessions that only
* hold the base execute:purchases verb or have no permissions at all.
*/

import { openPurchaseModal, getExecuteMode } from '../recommendations';

jest.mock('../api', () => ({
getRecommendations: jest.fn().mockResolvedValue({ summary: {}, recommendations: [], regions: [] }),
refreshRecommendations: jest.fn(),
getConfig: jest.fn().mockResolvedValue({ global: {} }),
listAccounts: jest.fn().mockResolvedValue([]),
listAccountServiceOverrides: jest.fn().mockResolvedValue([]),
}));

jest.mock('../api/recommendations', () => ({
getRecommendationsFreshness: jest.fn().mockResolvedValue({
last_collected_at: new Date().toISOString(),
last_collection_error: null,
}),
refreshRecommendations: jest.fn().mockResolvedValue({}),
}));

jest.mock('../state', () => ({
getCurrentProvider: jest.fn().mockReturnValue('all'),
setCurrentProvider: jest.fn(),
getCurrentAccountIDs: jest.fn().mockReturnValue([]),
setCurrentAccountIDs: jest.fn(),
getRecommendations: jest.fn().mockReturnValue([]),
getRecommendationByID: jest.fn().mockReturnValue(undefined),
setRecommendations: jest.fn(),
getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()),
clearSelectedRecommendations: jest.fn(),
addSelectedRecommendation: jest.fn(),
removeSelectedRecommendation: jest.fn(),
getRecommendationsSort: jest.fn().mockReturnValue({ column: 'savings', direction: 'desc' }),
setRecommendationsSort: jest.fn(),
getRecommendationsColumnFilters: jest.fn().mockReturnValue({}),
setRecommendationsColumnFilter: jest.fn(),
clearAllRecommendationsColumnFilters: jest.fn(),
getVisibleRecommendations: jest.fn().mockReturnValue([]),
setVisibleRecommendations: jest.fn(),
getCostPeriod: jest.fn().mockReturnValue('monthly'),
setCostPeriod: jest.fn(),
getHiddenColumns: jest.fn().mockReturnValue(new Set()),
setHiddenColumns: jest.fn(),
getCurrentUser: jest.fn(),
}));

jest.mock('../toast', () => ({
showToast: jest.fn().mockReturnValue({ dismiss: jest.fn() }),
}));

import * as state from '../state';

type UserRole = 'admin' | 'user' | 'readonly';
const mockUser = (role: UserRole | null) => {
(state.getCurrentUser as jest.Mock).mockReturnValue(
role === null ? null : { id: 'u', email: 'u@example.com', role },
);
};

const minimalRec = {
id: 'r1',
provider: 'aws',
service: 'ec2',
region: 'us-east-1',
resource_type: 'm5.xlarge',
engine: '',
count: 1,
term: 1,
payment: 'all-upfront',
upfront_cost: 1000,
savings: 200,
selected: false,
purchased: false,
};

const setupPurchaseModal = () => {
const modal = document.createElement('div');
modal.id = 'purchase-modal';
modal.setAttribute('aria-modal', 'true');
const details = document.createElement('div');
details.id = 'purchase-details';
modal.appendChild(details);
document.body.appendChild(modal);

// execute-purchase-btn is referenced in updateExecuteMode
const btn = document.createElement('button');
btn.id = 'execute-purchase-btn';
btn.textContent = 'Send for Approval';
document.body.appendChild(btn);

return { modal, details };
};

describe('Execute-mode toggle (issue #289)', () => {
beforeEach(() => {
jest.clearAllMocks();
document.body.innerHTML = '';
});

test('admin sees the execute-mode toggle in the purchase modal', async () => {
mockUser('admin');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);
const details = document.getElementById('purchase-details')!;
const toggle = details.querySelector('.execute-mode-toggle');
expect(toggle).not.toBeNull();
// Both radio buttons must be present
expect(details.querySelector('#execute-mode-approval')).not.toBeNull();
expect(details.querySelector('#execute-mode-direct')).not.toBeNull();
});

test('user without execute-own/execute-any sees only the approval note (no toggle)', async () => {
mockUser('user');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);
const details = document.getElementById('purchase-details')!;
expect(details.querySelector('.execute-mode-toggle')).toBeNull();
expect(details.querySelector('.approval-required-note')).not.toBeNull();
});

test('readonly user sees only the approval note (no toggle)', async () => {
mockUser('readonly');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);
const details = document.getElementById('purchase-details')!;
expect(details.querySelector('.execute-mode-toggle')).toBeNull();
expect(details.querySelector('.approval-required-note')).not.toBeNull();
});

test('getExecuteMode defaults to "" (approval path) after modal open', async () => {
mockUser('admin');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);
expect(getExecuteMode()).toBe('');
});

test('selecting Execute Now radio sets execute mode to "direct"', async () => {
mockUser('admin');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);

const directRadio = document.getElementById('execute-mode-direct') as HTMLInputElement;
expect(directRadio).not.toBeNull();
directRadio.click();
directRadio.dispatchEvent(new Event('change', { bubbles: true }));

expect(getExecuteMode()).toBe('direct');

// The submit button label should update.
const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
expect(btn.textContent).toBe('Execute Purchase Now');
});

test('switching back to Send for Approval resets execute mode', async () => {
mockUser('admin');
setupPurchaseModal();
await openPurchaseModal([minimalRec as never]);

// Switch to direct
const directRadio = document.getElementById('execute-mode-direct') as HTMLInputElement;
directRadio.click();
directRadio.dispatchEvent(new Event('change', { bubbles: true }));
expect(getExecuteMode()).toBe('direct');

// Switch back to approval
const approvalRadio = document.getElementById('execute-mode-approval') as HTMLInputElement;
approvalRadio.click();
approvalRadio.dispatchEvent(new Event('change', { bubbles: true }));
expect(getExecuteMode()).toBe('');

const btn = document.getElementById('execute-purchase-btn') as HTMLButtonElement;
expect(btn.textContent).toBe('Send for Approval');
});
});
19 changes: 17 additions & 2 deletions frontend/src/api/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,27 @@ import type {
* choice (1..100), recorded on the execution for audit; backend
* math uses the already-scaled counts in the recommendations list.
* Omit or pass 100 for "full capacity" (default).
*
* execute_mode controls the approval path (issue #289):
* - undefined / omitted: standard approval-required flow.
* - "direct": bypass the approval email and execute immediately.
* Requires the session to hold execute-any:purchases or
* execute-own:purchases; the backend returns 403 otherwise.
*/
export async function executePurchase(recommendations: Recommendation[], capacityPercent?: number): Promise<PurchaseResult> {
const body: { recommendations: Recommendation[]; capacity_percent?: number } = { recommendations };
export async function executePurchase(
recommendations: Recommendation[],
capacityPercent?: number,
executeMode?: string,
): Promise<PurchaseResult> {
const body: { recommendations: Recommendation[]; capacity_percent?: number; execute_mode?: string } = {
recommendations,
};
if (capacityPercent !== undefined && capacityPercent !== 100) {
body.capacity_percent = capacityPercent;
}
if (executeMode) {
body.execute_mode = executeMode;
}
return apiRequest<PurchaseResult>('/purchases/execute', {
method: 'POST',
body: JSON.stringify(body)
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ export interface PurchaseResult {
// alice@acme.com") per the CR pass on PR #294 / issue #288. Absent when
// recipient resolution itself failed (no approvers configured).
approval_recipient?: string;
// True when the request was handled via the direct-execute path (issue
// #289). Absent (undefined) on the standard approval-required flow.
direct_execute?: boolean;
results?: Array<{
recommendation_id: string;
status: string;
Expand Down
71 changes: 47 additions & 24 deletions frontend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import * as api from './api';
import * as state from './state';
import { showLoginModal, showAdminSetupModal, showResetPasswordModal, updateUserUI } from './auth';
import { loadDashboard, setupDashboardHandlers } from './dashboard';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, type FanOutBucket } from './recommendations';
import { setupRecommendationsHandlers, getPurchaseModalRecommendations, clearPurchaseModalRecommendations, getFanOutBuckets, clearFanOutBuckets, getExecuteMode, clearExecuteMode, type FanOutBucket } from './recommendations';
import { switchTab, applyTabFromPath, initRouter, switchSettingsSubTab, getSettingsSubTabFromPath } from './navigation';
import { savePlan, setupPlanHandlers, closePlanModal, openNewPlanModal, closePurchaseModal } from './plans';
import { saveGlobalSettings, setupSettingsHandlers, resetSettings } from './settings';
Expand Down Expand Up @@ -307,6 +307,12 @@ async function handleExecutePurchase(): Promise<void> {
return;
}

// Read the execute mode set by the modal toggle (issue #289).
// "direct" means the session has execute-any/execute-own and chose to
// bypass approval; "" is the default approval-required path.
const executeMode = getExecuteMode();
const isDirect = executeMode === 'direct';

// 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
Expand All @@ -317,22 +323,30 @@ async function handleExecutePurchase(): Promise<void> {
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.
// Issue #289 will introduce a session-permission branch where holders
// of `execute-any:purchases` can opt into direct execution; until that
// lands, every user is on this approval path.
const ok = await confirmDialog({
title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
confirmLabel: 'Send for approval',
destructive: false,
});
const defaultBtnLabel = isDirect ? 'Execute Purchase Now' : 'Send for Approval';

// Confirmation dialog varies by mode:
// - Approval path: low-friction, non-destructive.
// - Direct-execute path: red destructive dialog with cost callout and
// cancellation-window reminder (issue #289 acceptance criteria).
const ok = isDirect
? await confirmDialog({
title: `Execute ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} now?`,
body: 'This will charge the full upfront amount immediately. This bypasses the approval step. AWS allows cancellation within 24 hours via the Account & Billing console.',
confirmLabel: 'Execute Purchase Now',
destructive: true,
})
: await confirmDialog({
title: `Send ${localRecs.length} purchase${localRecs.length === 1 ? '' : 's'} for approval?`,
body: 'This will email an approval request to the configured approver. Cloud commitments are charged only after the approver clicks the link in that email.',
confirmLabel: 'Send for approval',
destructive: false,
});

if (!ok) {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = 'Send for Approval';
executeBtn.textContent = defaultBtnLabel;
}
return;
}
Expand Down Expand Up @@ -365,18 +379,26 @@ async function handleExecutePurchase(): Promise<void> {
: 100;

try {
const result = await api.executePurchase(apiRecs, capacityPercent);
const result = await api.executePurchase(apiRecs, capacityPercent, executeMode || undefined);
closePurchaseModal();
clearPurchaseModalRecommendations();
clearExecuteMode();

// The backend now surfaces email-send status so the toast can be honest
// about what the user should do next. When email_sent is undefined we
// fall back to the old "check your email" message for backward compat
// with any pre-deploy caller that hasn't picked up the new field yet.
if (result.email_sent === false) {
if (isDirect) {
// Direct-execute: purchase is already committed; inform the user.
showToast({
message: `Purchase executed immediately (id ${result.execution_id.slice(0, 8)}). Check Purchase History for the result.`,
kind: 'success',
timeout: 15_000,
});
} else if (result.email_sent === false) {
// The backend now surfaces email-send status so the toast can be honest
// about what the user should do next. When email_sent is undefined we
// fall back to the old "check your email" message for backward compat
// with any pre-deploy caller that hasn't picked up the new field yet.
const reason = result.email_reason || 'reason unavailable';
showToast({
message: `Purchase queued as pending (id ${result.execution_id.slice(0, 8)}…) but the approval email did not send: ${reason}. Approve or cancel it from the Purchase History tab.`,
message: `Purchase queued as pending (id ${result.execution_id.slice(0, 8)}) but the approval email did not send: ${reason}. Approve or cancel it from the Purchase History tab.`,
kind: 'warning',
timeout: null,
});
Expand All @@ -390,7 +412,7 @@ async function handleExecutePurchase(): Promise<void> {
showToast({
message: recipient
? `Approval request sent to ${recipient}.`
: 'Purchase submitted — check your email to approve.',
: 'Purchase submitted - check your email to approve.',
kind: 'success',
timeout: 10_000,
});
Expand All @@ -405,11 +427,12 @@ async function handleExecutePurchase(): Promise<void> {
await loadDashboard();
} catch (error) {
const err = error as Error;
showToast({ message: `Failed to send purchase for approval: ${err.message}`, kind: 'error' });
const verb = isDirect ? 'execute' : 'send for approval';
showToast({ message: `Failed to ${verb} purchase: ${err.message}`, kind: 'error' });
} finally {
if (executeBtn) {
executeBtn.disabled = false;
executeBtn.textContent = 'Send for Approval';
executeBtn.textContent = defaultBtnLabel;
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export type Action =
| 'retry-any'
| 'approve-own'
| 'approve-any'
| 'execute-own'
| 'execute-any'
| 'admin';

// Resource names. Closed enum for the same reason.
Expand Down
Loading
Loading