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
77 changes: 77 additions & 0 deletions frontend/src/__tests__/history-revoke-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* 4. already-revoked Azure row (revoked_at set) -> hidden.
* 5. non-Azure (aws/gcp) row with a window stamped -> hidden.
* 6. anonymous (no current user cached) -> hidden.
* 7. scheduled row on any provider (aws/gcp/azure) with a future window ->
* shown; a scheduled row whose fire time has passed -> hidden (#924).
*/

import { loadHistory } from '../history';
Expand Down Expand Up @@ -307,6 +309,81 @@ describe('History inline Revoke button (issue #290)', () => {
});
});

// issue #924: a scheduled row has not called any cloud SDK yet, so the backend
// cancels it for free on every provider. The Azure-only rule applies to
// completed rows (returns) only.
describe('History Revoke on scheduled rows of every provider (issue #924)', () => {
beforeEach(() => {
setupDOM();
jest.clearAllMocks();
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
});

test.each(['aws', 'gcp', 'azure'])('shows Revoke for a scheduled %s row with a future fire time', async (provider) => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: `exec-${provider}`, provider, status: 'scheduled', revocation_window_closes_at: FUTURE })],
});

await loadHistory();

expect(revokeIds()).toEqual([`exec-${provider}`]);
});

test.each(['aws', 'gcp'])('still hides Revoke for a completed %s row', async (provider) => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: `commit-${provider}`, provider, status: 'completed', revocation_window_closes_at: FUTURE })],
});

await loadHistory();

expect(revokeIds()).toEqual([]);
});

test('hides Revoke on a scheduled AWS row once its fire time has passed', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'exec-aws-late', provider: 'aws', status: 'scheduled', revocation_window_closes_at: PAST })],
});

await loadHistory();

expect(revokeIds()).toEqual([]);
});

test('hides Revoke on a scheduled GCP row without revoke permission', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(NON_REVOKER_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'exec-gcp', provider: 'gcp', status: 'scheduled', revocation_window_closes_at: FUTURE })],
});

await loadHistory();

expect(revokeIds()).toEqual([]);
});

test('scheduled AWS revoke: free cancel with provider-neutral copy, no quote', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeRow({ purchase_id: 'exec-aws', provider: 'aws', status: 'scheduled', revocation_window_closes_at: FUTURE })],
});
(confirmDialog as jest.Mock).mockResolvedValue(true);
(api.revokePurchase as jest.Mock).mockResolvedValue({ status: 'canceled' });

await loadHistory();
document.querySelector<HTMLButtonElement>('.history-revoke-btn')!.click();
await new Promise((resolve) => setTimeout(resolve, 0));

const body = (confirmDialog as jest.Mock).mock.calls[0][0].body as string;
expect(body).toContain('cancel the scheduled purchase');
expect(body).not.toContain('Azure');
expect(api.getRevokeQuote).not.toHaveBeenCalled();
expect(api.revokePurchase).toHaveBeenCalledWith('exec-aws', undefined);
});
});

// 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.
Expand Down
32 changes: 20 additions & 12 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,11 @@ function canRetryFailedRow(p: HistoryPurchase): boolean {
// Conditions:
// * status must be "completed", "" (legacy blank), or "scheduled"
// (pre-fire delay: the cloud SDK has not been called yet -- free cancel);
// * provider must be "azure" (AWS and GCP have no direct cancel API);
// * completed (and legacy blank) rows must be provider "azure": only Azure
// reservations can be returned after purchase, AWS and GCP have no direct
// cancel API. Scheduled rows are allowed for every provider because the
// backend cancels them via CancelScheduledExecutionAtomic before any
// cloud call (issue #924);
// * revocation_window_closes_at must be in the future;
// for "scheduled" rows this field is populated with scheduled_execution_at
// by the backend (issue #290, second-wave CR Finding E);
Expand All @@ -712,7 +716,7 @@ function canRetryFailedRow(p: HistoryPurchase): boolean {
function canRevokeCompletedRow(p: HistoryPurchase): boolean {
const status = (p.status || '').toLowerCase();
if (status !== 'completed' && status !== '' && status !== 'scheduled') return false;
if ((p.provider || '').toLowerCase() !== 'azure') return false;
if (status !== 'scheduled' && (p.provider || '').toLowerCase() !== 'azure') return false;
if (p.revoked_at) return false; // already revoked
if (!p.revocation_window_closes_at) return false;
if (new Date(p.revocation_window_closes_at) <= new Date()) return false;
Expand Down Expand Up @@ -977,15 +981,16 @@ function renderActionCell(p: HistoryPurchase): string {
// Cancel button, or be an Azure row still inside its revoke window.
const trailingActions: string[] = [];
if (p.purchase_id) {
// Completed Azure row within revocation window: Revoke button (issue
// #290). Only Azure supports direct in-app revocation; AWS and GCP have
// no cancel API so the button is suppressed for those providers.
// Revoke button (issue #290): completed Azure rows within the refund
// window, and scheduled rows on any provider before they fire (#924).
// Completed AWS and GCP rows have no cancel API, so they get no button.
if (canRevokeCompletedRow(p)) {
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
// Azure-only, marketplace is AWS Standard-RI-only).
// (issue #292). Mutually exclusive with revoke in practice (revoke on a
// completed row is Azure-only, marketplace is AWS Standard-RI-only and
// never applies to a scheduled row).
if (canCancelMarketplaceListing(p)) {
trailingActions.push(`<button type="button" class="btn-link history-marketplace-cancel-btn" data-marketplace-cancel-id="${escapeHtml(p.purchase_id)}">Cancel listing ${escapeHtml(p.listing_id || '')}</button>`);
} else if (canSellOnMarketplace(p)) {
Expand Down Expand Up @@ -1461,7 +1466,8 @@ function wireRowActionHandlers(container: HTMLElement): void {
});

// Wire the inline Revoke button on completed Azure rows within the
// free-cancel window (issue #290). quote -> confirmDialog -> POST -> reload.
// free-cancel window (issue #290) and on scheduled rows of any provider
// (#924). 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
Expand All @@ -1482,12 +1488,14 @@ function wireRowActionHandlers(container: HTMLElement): void {
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.';
// A scheduled row has not reached any cloud yet, so its copy must not
// promise an Azure return: it may be an AWS or GCP purchase (#924).
const body = quote
? `This will request an Azure reservation return. Azure will refund ${quote.refund_amount.toFixed(2)} ${quote.refund_currency}.`
: 'This will cancel the scheduled purchase. It 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. ${refundLine} This action cannot be undone.`,
body: `${body} This action cannot be undone.`,
confirmLabel: 'Revoke purchase',
destructive: true,
});
Expand Down
69 changes: 69 additions & 0 deletions frontend/tests-e2e/history-scheduled-revoke.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { test, expect, type Page } from '@playwright/test';
import { mockApi, seedAuth } from './fixtures/recs';

// Issue #924: a scheduled execution has not called any cloud SDK yet, so the
// backend revokes it for free on every provider. AWS and GCP scheduled rows
// must offer Revoke too; completed AWS and GCP rows still must not.

const FUTURE = () => new Date(Date.now() + 86_400_000).toISOString();

function row(id: string, provider: string, status: string) {
return {
purchase_id: id, timestamp: new Date().toISOString(), provider,
service: 'compute', resource_type: 'm5.large', region: 'us-east-1', count: 1, term: 1,
upfront_cost: 1200, monthly_cost: 50, estimated_savings: 10,
status, account_id: 'acct-001', created_by_user_id: 'user-smoke',
revocation_window_closes_at: FUTURE(),
};
}

async function fixture(page: Page) {
await seedAuth(page);
await mockApi(page);
const rows = [
row('aaaaaaaa-0000-0000-0000-000000000001', 'aws', 'scheduled'),
row('aaaaaaaa-0000-0000-0000-000000000002', 'gcp', 'scheduled'),
row('aaaaaaaa-0000-0000-0000-000000000003', 'aws', 'completed'),
];
const posts: string[] = [];
const quoteGets: string[] = [];
await page.route('**/api/auth/me/permissions', route => route.fulfill({ json: { permissions: [
{ action: 'admin', resource: '*' }, { action: 'revoke-any', resource: 'purchases' },
] } }));
await page.route('**/api/info/deployment', route => route.fulfill({ json: {} }));
await page.route('**/api/history**', route => route.fulfill({ json: { summary: {}, purchases: rows } }));
await page.route('**/api/purchases/**', async route => {
if (route.request().method() === 'POST') {
posts.push(route.request().url());
await route.fulfill({ json: { status: 'canceled' } });
} else {
quoteGets.push(route.request().url());
await route.fulfill({ json: { refund_amount: 1, refund_currency: 'USD' } });
}
});
return { posts, quoteGets };
}

test('scheduled AWS and GCP rows offer Revoke; completed AWS rows do not', async ({ page }) => {
await fixture(page);
await page.goto('/purchases/history');
const ids = page.locator('#history-list .history-revoke-btn');
await expect(ids).toHaveCount(2);
expect(await ids.evaluateAll(btns => btns.map(b => (b as HTMLElement).dataset['revokeId']))).toEqual([
'aaaaaaaa-0000-0000-0000-000000000001',
'aaaaaaaa-0000-0000-0000-000000000002',
]);
});

test('revoking a scheduled AWS row cancels it without a refund quote or Azure copy', async ({ page }) => {
const { posts, quoteGets } = await fixture(page);
await page.goto('/purchases/history');
await page.locator('#history-list .history-revoke-btn[data-revoke-id="aaaaaaaa-0000-0000-0000-000000000001"]').click();
const dialog = page.locator('.modal-confirm-backdrop');
await expect(dialog).toContainText('cancel the scheduled purchase');
await expect(dialog).not.toContainText('Azure');
await page.locator('.modal-confirm-actions button').last().click();
await expect.poll(() => posts.length).toBe(1);
expect(posts[0]).toContain('/purchases/aaaaaaaa-0000-0000-0000-000000000001/revoke');
expect(quoteGets.filter(u => u.includes('/calculate'))).toEqual([]);
});
Loading