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
57 changes: 57 additions & 0 deletions frontend/src/__tests__/history-revoke-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jest.mock('../api', () => ({
getHistory: jest.fn(),
getConfig: jest.fn().mockResolvedValue({ global: {} }),
revokePurchase: jest.fn(),
getRevokeQuote: jest.fn(),
}));

jest.mock('../navigation', () => ({
Expand Down Expand Up @@ -71,6 +72,7 @@ jest.mock('../state', () => ({

import * as api from '../api';
import { getCurrentUser } from '../state';
import { confirmDialog } from '../confirmDialog';

// Administrators group GUID -- mirrors ADMINISTRATORS_GROUP_ID in
// frontend/src/permissions.ts. Without this, isAdmin() returns false and
Expand Down Expand Up @@ -304,3 +306,58 @@ describe('History inline Revoke button (issue #290)', () => {
expect(revokeIds()).toContain('commit-scheduled');
});
});

// platform#96: the backend refuses an Azure return unless the body carries the
// quote the user consented to, so the click flow must fetch the quote, show it,
// and send amount + currency back.
describe('History Revoke click flow (platform#96)', () => {
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));

beforeEach(() => {
setupDOM();
jest.clearAllMocks();
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
});

async function clickRevoke(status: string): Promise<void> {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'commit-azure', status, revocation_window_closes_at: FUTURE })],
});
await loadHistory();
document.querySelector<HTMLButtonElement>('.history-revoke-btn')!.click();
await flush();
}

test('completed row: fetches the quote, shows it, and sends it with the revoke', async () => {
const quote = { refund_amount: 4200, refund_currency: 'EUR', quoted_at: '2024-01-16T00:00:00Z' };
(api.getRevokeQuote as jest.Mock).mockResolvedValue(quote);
(confirmDialog as jest.Mock).mockResolvedValue(true);
(api.revokePurchase as jest.Mock).mockResolvedValue({ status: 'revoked' });

await clickRevoke('completed');

expect(api.getRevokeQuote).toHaveBeenCalledWith('commit-azure');
expect((confirmDialog as jest.Mock).mock.calls[0][0].body).toContain('4200.00 EUR');
expect(api.revokePurchase).toHaveBeenCalledWith('commit-azure', quote);
});

test('completed row: a failed quote never reaches the revoke call', async () => {
(api.getRevokeQuote as jest.Mock).mockRejectedValue(new Error('boom'));

await clickRevoke('completed');

expect(confirmDialog).not.toHaveBeenCalled();
expect(api.revokePurchase).not.toHaveBeenCalled();
});

test('scheduled row: free cancel, no quote requested', async () => {
(confirmDialog as jest.Mock).mockResolvedValue(true);
(api.revokePurchase as jest.Mock).mockResolvedValue({ status: 'cancelled' });

await clickRevoke('scheduled');

expect(api.getRevokeQuote).not.toHaveBeenCalled();
expect(api.revokePurchase).toHaveBeenCalledWith('commit-azure', undefined);
});
});
3 changes: 2 additions & 1 deletion frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,11 @@ export {
deletePlannedPurchase,
createPlannedPurchases,
revokePurchase,
getRevokeQuote,
createMarketplaceListing,
cancelMarketplaceListing
} from './purchases';
export type { RetryPurchaseResult, RevokePurchaseResult, MarketplacePriceTier, MarketplaceListResult } from './purchases';
export type { RetryPurchaseResult, RevokePurchaseResult, RevokeQuote, MarketplacePriceTier, MarketplaceListResult } from './purchases';

// Re-export users functions
export {
Expand Down
30 changes: 27 additions & 3 deletions frontend/src/api/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ import type {
Recommendation,
PurchaseResult,
PurchaseDetails,
PlannedPurchasesResponse
PlannedPurchasesResponse,
RequestOptions
} from './types';

/**
Expand Down Expand Up @@ -84,8 +85,31 @@ export interface RevokePurchaseResult {
revoked_via: string;
}

export async function revokePurchase(purchaseId: string): Promise<RevokePurchaseResult> {
return apiRequest<RevokePurchaseResult>(`/purchases/${purchaseId}/revoke`, { method: 'POST' });
/** Azure refund quote from GET /purchases/{id}/revoke/calculate. */
export interface RevokeQuote {
refund_amount: number;
refund_currency: string;
quoted_at: string;
}

export async function getRevokeQuote(purchaseId: string): Promise<RevokeQuote> {
return apiRequest<RevokeQuote>(`/purchases/${purchaseId}/revoke/calculate`);
}

/**
* The backend requires `quote` for completed Azure rows and rejects the
* return if Azure's current quote differs in amount or currency. Scheduled
* (pre-fire) rows are a free cancel and take no quote.
*/
export async function revokePurchase(purchaseId: string, quote?: RevokeQuote): Promise<RevokePurchaseResult> {
const init: RequestOptions = { method: 'POST' };
if (quote) {
init.body = JSON.stringify({
expected_refund_amount: quote.refund_amount,
expected_refund_currency: quote.refund_currency,
});
}
return apiRequest<RevokePurchaseResult>(`/purchases/${purchaseId}/revoke`, init);
}

/**
Expand Down
25 changes: 21 additions & 4 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as api from './api';
import * as state from './state';
import { formatCurrency, formatDate, formatTerm, escapeHtml, escapeHtmlAttr, amortizedMonthly } from './utils';
import type { HistoryResponse, HistorySummary, HistoryPurchase } from './types';
import type { RevokeQuote } from './api';
import { switchTab } from './navigation';
import { confirmDialog } from './confirmDialog';
import { buildApprovalDetailsBody } from './approval-details';
Expand Down Expand Up @@ -899,7 +900,7 @@ function renderActionCell(p: HistoryPurchase): string {
// #290). Only Azure supports direct in-app revocation; AWS and GCP have
// no cancel API so the button is suppressed for those providers.
if (canRevokeCompletedRow(p)) {
trailingActions.push(`<button type="button" class="btn-link history-revoke-btn" data-revoke-id="${escapeHtml(p.purchase_id)}">Revoke</button>`);
trailingActions.push(`<button type="button" class="btn-link history-revoke-btn" data-revoke-id="${escapeHtml(p.purchase_id)}" data-revoke-scheduled="${(p.status || '').toLowerCase() === 'scheduled'}">Revoke</button>`);
}
// Completed Standard RI rows (AWS): Cancel listing / Sell on Marketplace
// (issue #292). Mutually exclusive with revoke in practice (revoke is
Expand Down Expand Up @@ -1406,25 +1407,41 @@ function wireRowActionHandlers(container: HTMLElement): void {
});

// Wire the inline Revoke button on completed Azure rows within the
// free-cancel window (issue #290). confirmDialog -> POST -> reload.
// free-cancel window (issue #290). quote -> confirmDialog -> POST -> reload.
// Completed rows fetch the Azure refund quote first so the user consents to
// a concrete amount, which the backend re-checks before issuing the refund.
// The backend is the security boundary; canRevokeCompletedRow is a
// UX gate that hides the button when the call would fail, but a stale
// cache can still surface a 4xx -- handle it like any other failure.
container.querySelectorAll<HTMLButtonElement>('.history-revoke-btn[data-revoke-id]').forEach(btn => {
btn.addEventListener('click', async () => {
const id = btn.dataset['revokeId'];
if (!id) return;
let quote: RevokeQuote | undefined;
if (btn.dataset['revokeScheduled'] !== 'true') {
try {
quote = await api.getRevokeQuote(id);
} catch (quoteError) {
console.error('Failed to get refund quote:', quoteError);
const err = quoteError as Error;
showToast({ message: `Failed to get refund quote: ${err.message || 'unknown error'}`, kind: 'error' });
return;
}
}
const refundLine = quote
? `Azure will refund ${quote.refund_amount.toFixed(2)} ${quote.refund_currency}.`
: 'The scheduled purchase has not been submitted yet, so nothing will be charged.';
const ok = await confirmDialog({
title: 'Revoke this purchase within the free-cancel window?',
body: 'This will request an Azure reservation return. The charge will be refunded if the request is within the 7-day window. This action cannot be undone.',
body: `This will request an Azure reservation return. ${refundLine} This action cannot be undone.`,
confirmLabel: 'Revoke purchase',
destructive: true,
});
if (!ok) return;
const rowActions = sameRowActions(btn);
rowActions.forEach((b) => { b.disabled = true; });
try {
await api.revokePurchase(id);
await api.revokePurchase(id, quote);
} catch (revokeError) {
console.error('Failed to revoke purchase:', revokeError);
const err = revokeError as Error;
Expand Down
79 changes: 48 additions & 31 deletions internal/api/handler_purchases_revoke.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,12 @@ type revokeQuoteResult struct {

// revokeConfirmBody is the JSON body expected on
// POST /api/purchases/{purchaseId}/revoke.
// ExpectedRefundAmount is the amount the user consented to after seeing the
// quote, used for TOCTOU-divergence detection.
// ExpectedRefundAmount and ExpectedRefundCurrency are the quote the user
// consented to (from GET /revoke/calculate), used for TOCTOU-divergence
// detection. Both are required for Azure revocations.
type revokeConfirmBody struct {
// ExpectedRefundAmount is the refund amount the user confirmed.
// Required when the purchase has an Azure revocation window.
ExpectedRefundAmount *float64 `json:"expected_refund_amount"`
ExpectedRefundAmount *float64 `json:"expected_refund_amount"`
ExpectedRefundCurrency string `json:"expected_refund_currency"`
}

// AzureRevocationWindowDays is the number of days after purchase within which
Expand Down Expand Up @@ -211,17 +211,17 @@ func (h *Handler) loadAndRevokePurchaseHistory(ctx context.Context, req *events.
}, nil
}

// Parse the optional expected_refund_amount from the request body.
// Azure revocations require this for TOCTOU-divergence detection
// (the two-step quote-then-confirm flow, issue #290 Finding #4).
// Parse the confirmed quote from the request body. Azure revocations
// require it for TOCTOU-divergence detection (the two-step
// quote-then-confirm flow, issue #290 Finding #4); callAzureReturn enforces it.
var body revokeConfirmBody
if req.Body != "" {
if jsonErr := json.Unmarshal([]byte(req.Body), &body); jsonErr != nil {
return nil, NewClientError(400, fmt.Sprintf("invalid request body: %v", jsonErr))
}
}

return h.dispatchProviderRevoke(ctx, record, body.ExpectedRefundAmount)
return h.dispatchProviderRevoke(ctx, record, body)
}

// revokeScheduledExecution cancels a Gmail-style pre-fire delayed execution
Expand Down Expand Up @@ -337,10 +337,10 @@ func (h *Handler) authorizeSessionRevokeExecution(ctx context.Context, session *
// dispatchProviderRevoke routes a revocation request to the correct
// provider-specific implementation. Extracted from revokePurchase to keep
// that function's cyclomatic complexity within the project limit.
func (h *Handler) dispatchProviderRevoke(ctx context.Context, record *config.PurchaseHistoryRecord, expectedRefundAmount *float64) (any, error) {
func (h *Handler) dispatchProviderRevoke(ctx context.Context, record *config.PurchaseHistoryRecord, confirmed revokeConfirmBody) (any, error) {
switch record.Provider {
case "azure":
return h.revokeAzurePurchase(ctx, record, expectedRefundAmount)
return h.revokeAzurePurchase(ctx, record, confirmed)
case "aws":
// AWS does not expose a direct RI cancel API. Phase 2 (#291) adds the
// AWS Support case path. Return 422 so the frontend hides this button.
Expand Down Expand Up @@ -417,7 +417,7 @@ func (h *Handler) checkRevokeOwnAccountAccess(ctx context.Context, userID string
//
// This is the first step of the two-step quote-then-confirm revoke UX
// (issue #290 Finding #4). No state is mutated; the result is used by the
// frontend to populate revokeConfirmBody.ExpectedRefundAmount.
// frontend to populate revokeConfirmBody.
func (h *Handler) calculateAzureRevoke(ctx context.Context, req *events.LambdaFunctionURLRequest, purchaseID string) (any, error) {
_, orderID, reservationID, count, err := h.validateAzureRevokeRequest(ctx, req, purchaseID)
if err != nil {
Expand Down Expand Up @@ -546,7 +546,7 @@ func extractAzureRefundQuote(resp armreservations.CalculateRefundClientPostRespo
// Reservations API (CalculateRefund + Return). The reservation order ID and
// reservation ID are parsed from the purchase_id ARM resource path stored at
// purchase time.
func (h *Handler) revokeAzurePurchase(ctx context.Context, record *config.PurchaseHistoryRecord, expectedRefundAmount *float64) (any, error) {
func (h *Handler) revokeAzurePurchase(ctx context.Context, record *config.PurchaseHistoryRecord, confirmed revokeConfirmBody) (any, error) {
// Prefer the window stamped on the row at purchase time (single source of
// truth, issue #290). Fall back to recomputing from Timestamp for legacy
// rows written before the column was populated, so they remain revocable.
Expand Down Expand Up @@ -574,7 +574,7 @@ func (h *Handler) revokeAzurePurchase(ctx context.Context, record *config.Purcha
return nil, fmt.Errorf("revoke azure: %w", err)
}

return h.callAzureReturn(ctx, calcClient, returnClient, record, orderID, reservationID, expectedRefundAmount)
return h.callAzureReturn(ctx, calcClient, returnClient, record, orderID, reservationID, confirmed)
}

func (h *Handler) getAzureRevokeFactory() azureRevokeClientFactory {
Expand Down Expand Up @@ -630,17 +630,15 @@ func (h *Handler) buildAzureRevokeClients() (azureCalculateRefundClient, azureRe
// CalculateRefund (to get the session ID and quoted amount) followed by Return.
// Extracted from revokeAzurePurchase to allow test injection of the two clients.
//
// expectedRefundAmount: the amount the user consented to after the
// quote step (GET /revoke/calculate). When provided and the CalculateRefund
// response diverges by more than revokeQuoteEpsilon, the call is rejected with
// 422 so the user can re-quote and confirm the new amount.
// confirmed: the quote the user consented to after the quote step
// (GET /revoke/calculate). It is mandatory; see checkConfirmedRefund.
func (h *Handler) callAzureReturn(
ctx context.Context,
calcClient azureCalculateRefundClient,
returnClient azureReturnClient,
record *config.PurchaseHistoryRecord,
orderID, reservationID string,
expectedRefundAmount *float64,
confirmed revokeConfirmBody,
) (any, error) {
// Guard against an order-only ARM path (no /reservations/{id} segment),
// which parseAzureReservationIDs returns with an empty reservationID.
Expand All @@ -650,6 +648,9 @@ func (h *Handler) callAzureReturn(
if orderID == "" || reservationID == "" {
return nil, NewClientError(422, "cannot determine Azure reservation ID from purchase record; contact Azure Support to request a refund")
}
if confirmed.ExpectedRefundAmount == nil || strings.TrimSpace(confirmed.ExpectedRefundCurrency) == "" {
return nil, NewClientError(400, "expected_refund_amount and expected_refund_currency are required; fetch a quote from GET /api/purchases/{id}/revoke/calculate and confirm it")
}

// Step 1: CalculateRefund -> sessionID + quoted amount (TOCTOU check).
quantity := int32(record.Count) // #nosec G115 -- Azure reservation count validated at purchase; bounded by API limits (<<math.MaxInt32) //nolint:gosec
Expand All @@ -658,18 +659,8 @@ func (h *Handler) callAzureReturn(
return nil, err
}

// TOCTOU-divergence check: if the caller supplied an expected refund amount
// (from the prior GET /revoke/calculate), verify it matches the current
// CalculateRefund response within epsilon. A mismatch means Azure's refund
// quote changed between the user's confirmation and the actual call (e.g.
// partial return already submitted, time-based fee tier changed).
if expectedRefundAmount != nil && calcRefundAmount != nil {
if math.Abs(*expectedRefundAmount-*calcRefundAmount) > revokeQuoteEpsilon {
return nil, NewClientError(422, fmt.Sprintf(
"refund amount diverged: you confirmed %.2f but Azure now quotes %.2f %s; re-confirm to proceed",
*expectedRefundAmount, *calcRefundAmount, calcRefundCurrency,
))
}
if quoteErr := checkConfirmedRefund(confirmed, calcRefundAmount, calcRefundCurrency); quoteErr != nil {
return nil, quoteErr
}

// Partial-success guard (issue #290 Finding #6): flip the in-flight flag
Expand Down Expand Up @@ -700,6 +691,32 @@ func (h *Handler) callAzureReturn(
return h.persistAzureRevocation(ctx, record, calcRefundAmount, calcRefundCurrency)
}

// checkConfirmedRefund is the TOCTOU-divergence check: the refund the user
// confirmed must match Azure's current CalculateRefund quote in currency and,
// within revokeQuoteEpsilon, in amount. A mismatch means the quote changed
// between confirmation and the call (e.g. a fee tier boundary was crossed).
// It fails closed when Azure returns no comparable quote, and never converts
// currencies (there is no trusted FX source).
func checkConfirmedRefund(confirmed revokeConfirmBody, calcRefundAmount *float64, calcRefundCurrency string) error {
if calcRefundAmount == nil || strings.TrimSpace(calcRefundCurrency) == "" {
return NewClientError(422, "Azure returned no refund quote to verify against the amount you confirmed; refusing to return the reservation")
}
expectedCurrency := strings.TrimSpace(confirmed.ExpectedRefundCurrency)
if !strings.EqualFold(expectedCurrency, strings.TrimSpace(calcRefundCurrency)) {
return NewClientError(422, fmt.Sprintf(
"refund currency diverged: you confirmed %.2f %s but Azure now quotes %.2f %s; re-confirm to proceed",
*confirmed.ExpectedRefundAmount, expectedCurrency, *calcRefundAmount, calcRefundCurrency,
))
}
if math.Abs(*confirmed.ExpectedRefundAmount-*calcRefundAmount) > revokeQuoteEpsilon {
return NewClientError(422, fmt.Sprintf(
"refund amount diverged: you confirmed %.2f but Azure now quotes %.2f %s; re-confirm to proceed",
*confirmed.ExpectedRefundAmount, *calcRefundAmount, calcRefundCurrency,
))
}
return nil
}

// azureCalculateRefund runs the CalculateRefund step and parses out the session
// ID (required by Return) and the quoted refund amount/currency (for the TOCTOU
// check). Errors are classified into 400 (client) vs 500 (transient).
Expand Down
Loading
Loading