Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
778905b
feat(purchases): in-app revocation within free-cancel window (closes …
cristim May 28, 2026
ff8aea7
fix(ci): extract provider dispatch and account check to reduce revoke…
cristim Jun 1, 2026
f856df4
fix(api/purchases): align revoke admin check with group-only authz (#…
cristim Jun 3, 2026
824fad1
fix(api/purchases/revoke): fail-closed on nil account + return error …
cristim Jun 4, 2026
d2186ac
fix(purchases/revoke): populate revocation window at write path so th…
cristim Jun 5, 2026
69d6dc4
docs(auth/revoke): correct revoke-own doc to account-scope reality (#…
cristim Jun 5, 2026
d7a3363
feat(purchases/revoke): Gmail-style pre-fire delay unifies revoke acr…
cristim Jun 5, 2026
36a419b
refactor(api/config): extract helpers to keep revoke+approve+email+sc…
cristim Jun 5, 2026
4539ae5
refactor(test/mocks): consolidate MockConfigStore into shared interna…
cristim Jun 5, 2026
1de14ca
fix(api/purchases/revoke): use status='scheduled' CAS so pre-fire rev…
cristim Jun 6, 2026
d82cea6
fix(frontend/history): gate Revoke button on revoke-{any,own} permission
cristim Jun 6, 2026
a0f506b
test(frontend/permissions): update USER_PERMS expected set for revoke…
cristim Jun 6, 2026
a9c2614
fix(server): table-drive ParseScheduledEvent + cover scheduled-fire s…
cristim Jun 8, 2026
9129704
fix(migrations): renumber 000068 -> 000070 to deconflict (refs #290)
cristim Jun 8, 2026
e6d8d84
fix(scheduler): wire FireScheduledDelayedPurchases tick (CRITICAL: pr…
cristim Jun 8, 2026
072e734
fix(api/purchases): CAS-guard scheduleApprovedExecution to prevent si…
cristim Jun 8, 2026
0aabf7f
feat(purchases/revoke): two-step quote-then-confirm + persist refund …
cristim Jun 8, 2026
b67cfd4
fix(purchases/revoke): partial-success reconciliation; never retry a …
cristim Jun 8, 2026
72493ce
fix(purchases/revoke): typed Azure error classification (no more subs…
cristim Jun 8, 2026
ab80d51
fix(purchases/revoke): 1h safety margin on local window + clean 422 o…
cristim Jun 8, 2026
e0a9063
fix(migrations): allow support-case revoke to record in-flight state …
cristim Jun 8, 2026
1685954
test(purchases/revoke): DST-crossing window math + 4-eyes approval pl…
cristim Jun 8, 2026
b8fde55
fix(api/purchases): map scheduleApprovedExecution CAS race to 409 (no…
cristim Jun 8, 2026
41dab3c
fix(api/purchases/revoke): drop pre-check, distinguish GetExecutionBy…
cristim Jun 8, 2026
eb265ad
fix(api/purchases/revoke): clear revocation_in_flight on Azure error …
cristim Jun 8, 2026
15e8c53
fix(frontend/history): expose Revoke button for status='scheduled' ro…
cristim Jun 8, 2026
1f53b6f
fix(test/purchases/revoke): fix AssertNotCalled placement + isolate p…
cristim Jun 8, 2026
6486e1e
fix(test/history-revoke): add missing mock entries for escapeHtmlAttr…
cristim Jun 8, 2026
079c7b0
fix(purchases/revoke): let the CAS decide scheduled cancellability (#…
cristim Jun 8, 2026
bcdb792
fix(email/revoke): require recipient for scheduled-delay email; tidy …
cristim Jun 8, 2026
ee14a18
refactor(purchases): reduce cyclomatic complexity below the pre-commi…
cristim Jun 8, 2026
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
299 changes: 299 additions & 0 deletions frontend/src/__tests__/history-revoke-button.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,299 @@
/**
* History inline Revoke button tests (issue #290).
*
* Regression guard for the "dead Revoke button" gap: canRevokeCompletedRow
* gates the inline Revoke button on `revocation_window_closes_at`, which the
* backend now stamps at purchase-write time for Azure rows. Before that stamp
* existed, the field was always absent and the button never rendered on real
* rows.
*
* The backend authorizeSessionRevoke remains the real security boundary; these
* tests only verify the UX gate (don't render a button users can't use, do
* render it when the row is genuinely revocable).
*
* Tested matrix:
* 1. completed Azure row WITH a future revocation_window_closes_at -> shown.
* 2. completed Azure row WITHOUT revocation_window_closes_at -> hidden.
* 3. completed Azure row with a PAST revocation_window_closes_at -> hidden.
* 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.
*/

import { loadHistory } from '../history';

jest.mock('../api', () => ({
getHistory: jest.fn(),
revokePurchase: jest.fn(),
}));

jest.mock('../navigation', () => ({
switchTab: jest.fn(),
}));

jest.mock('../utils', () => ({
formatCurrency: jest.fn((val) => `$${val || 0}`),
formatDate: jest.fn((val) => (val ? new Date(val).toLocaleDateString() : '')),
formatTerm: jest.fn((years) => (years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`)),
escapeHtml: jest.fn((str) => str || ''),
escapeHtmlAttr: jest.fn((str) => str || ''),
amortizedMonthly: jest.fn((monthly) => monthly),
populateAccountFilter: jest.fn(() => Promise.resolve()),
}));

jest.mock('../confirmDialog', () => ({
confirmDialog: jest.fn(),
}));

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

jest.mock('../state', () => ({
getCurrentUser: jest.fn(),
getCurrentProvider: jest.fn().mockReturnValue(''),
setCurrentProvider: jest.fn(),
getCurrentAccountIDs: jest.fn().mockReturnValue([]),
setCurrentAccountIDs: jest.fn(),
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
}));

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

// Administrators group GUID -- mirrors ADMINISTRATORS_GROUP_ID in
// frontend/src/permissions.ts. Without this, isAdmin() returns false and
// canAccess('admin', '*') / canAccess('revoke-any', 'purchases') in the
// fallback branch don't grant. Seeded-group GUID, not the label string.
const ADMIN_GROUP_ID = '00000000-0000-5000-8000-000000000001';
const ADMIN_USER = { id: 'admin-uuid', email: 'admin@example.com', groups: [ADMIN_GROUP_ID] };
// Plain authenticated user with no revoke verbs in their effective set --
// used by the RBAC regression test below.
const NON_REVOKER_USER = {
id: 'plain-uuid',
email: 'plain@example.com',
groups: [],
effectivePermissions: [
{ action: 'view', resource: 'history' },
],
};

const FUTURE = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(); // +5 days
const PAST = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString(); // -1 hour

function setupDOM(): void {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);

const mkInput = (id: string): HTMLInputElement => {
const el = document.createElement('input');
el.type = 'date';
el.id = id;
return el;
};
const mkSelect = (id: string): HTMLSelectElement => {
const el = document.createElement('select');
el.id = id;
const opt = document.createElement('option');
opt.value = '';
opt.textContent = 'All';
el.appendChild(opt);
return el;
};
const mkDiv = (id: string): HTMLDivElement => {
const el = document.createElement('div');
el.id = id;
return el;
};

document.body.appendChild(mkInput('history-start'));
document.body.appendChild(mkInput('history-end'));
document.body.appendChild(mkSelect('history-provider-filter'));
document.body.appendChild(mkSelect('history-account-filter'));
document.body.appendChild(mkDiv('history-summary'));
document.body.appendChild(mkDiv('history-list'));
document.body.appendChild(mkDiv('purchases-approval-queue'));
}

function makeRow(overrides: Record<string, unknown>) {
return {
purchase_id: 'commit-1',
timestamp: '2024-01-15T00:00:00Z',
provider: 'azure',
service: 'compute',
resource_type: 'Standard_D2s_v3',
region: 'eastus',
count: 1,
term: 1,
upfront_cost: 100,
estimated_savings: 50,
plan_name: '',
status: 'completed',
...overrides,
};
}

function revokeIds(): (string | undefined)[] {
const list = document.getElementById('history-list')!;
const buttons = list.querySelectorAll<HTMLButtonElement>('.history-revoke-btn');
return Array.from(buttons).map((b) => b.dataset['revokeId']);
}

describe('History inline Revoke button (issue #290)', () => {
beforeEach(() => {
setupDOM();
jest.clearAllMocks();
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN_USER);
});

test('shows Revoke for a completed Azure row WITH a future revocation window', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-azure', revocation_window_closes_at: FUTURE }),
],
});

await loadHistory();

expect(revokeIds()).toEqual(['commit-azure']);
});

test('hides Revoke for a completed Azure row WITHOUT a revocation window', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-no-window' }), // revocation_window_closes_at absent
],
});

await loadHistory();

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

test('hides Revoke once the stamped window has closed', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-closed', revocation_window_closes_at: PAST }),
],
});

await loadHistory();

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

test('hides Revoke for an already-revoked row', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
purchase_id: 'commit-revoked',
revocation_window_closes_at: FUTURE,
revoked_at: '2024-01-16T00:00:00Z',
}),
],
});

await loadHistory();

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

test('hides Revoke for non-Azure rows even with a window stamped', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-aws', provider: 'aws', revocation_window_closes_at: FUTURE }),
makeRow({ purchase_id: 'commit-gcp', provider: 'gcp', revocation_window_closes_at: FUTURE }),
],
});

await loadHistory();

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

test('hides Revoke when no user is cached (anonymous)', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(null);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-anon', revocation_window_closes_at: FUTURE }),
],
});

await loadHistory();

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

// Regression guard for the missing RBAC gate (PR #804 review pass).
// canRevokeCompletedRow previously returned true for any signed-in user --
// the button rendered, then the backend 403d on click, replicating the
// same UX-vs-RBAC drift PR #995 caught for the approve / delete paths.
// canCancelPendingRow / canApprovePendingRow / canRetryFailedRow all check
// canAccess; canRevokeCompletedRow must do the same. With an explicit
// effectivePermissions set lacking revoke-* the button must be hidden.
test('hides Revoke when the session has no revoke-* permission', async () => {
(getCurrentUser as jest.Mock).mockReturnValue(NON_REVOKER_USER);
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({ purchase_id: 'commit-noperms', revocation_window_closes_at: FUTURE }),
],
});

await loadHistory();

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

// Regression guard: legacy rows written before the status column existed have
// status='' (empty string). canRevokeCompletedRow must treat blank status the
// same as "completed" so these rows remain revocable for Azure.
test('shows Revoke for a legacy blank-status Azure row with a future revocation window', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
status: '',
provider: 'azure',
purchase_id: 'commit-legacy-blank-status',
revocation_window_closes_at: FUTURE,
}),
],
});

await loadHistory();

expect(revokeIds()).toContain('commit-legacy-blank-status');
});

// Regression guard: Gmail-style pre-fire delay creates rows with
// status='scheduled' (cloud SDK not yet called). canRevokeCompletedRow must
// accept this status so the Revoke button renders before the execution fires
// (issue #290, second-wave CR Findings E + G).
test('shows Revoke for a scheduled Azure row with a future revocation window', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeRow({
status: 'scheduled',
provider: 'azure',
purchase_id: 'commit-scheduled',
revocation_window_closes_at: FUTURE,
}),
],
});

await loadHistory();

expect(revokeIds()).toContain('commit-scheduled');
});
});
3 changes: 3 additions & 0 deletions frontend/src/__tests__/permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ describe('permissions', () => {
'cancel-own:purchases',
'retry-own:purchases',
'approve-own:purchases',
// Added by PR #804: revoke-own gates the History inline Revoke button
// for completed Azure purchases within the free-cancel window.
'revoke-own:purchases',
];
expected.forEach((p) => expect(perms.has(p)).toBe(true));
expect(perms.size).toBe(expected.length);
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,9 +140,10 @@ export {
resumePlannedPurchase,
runPlannedPurchase,
deletePlannedPurchase,
createPlannedPurchases
createPlannedPurchases,
revokePurchase
} from './purchases';
export type { RetryPurchaseResult } from './purchases';
export type { RetryPurchaseResult, RevokePurchaseResult } from './purchases';

// Re-export users functions
export {
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/api/purchases.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,33 @@ export async function cancelPurchase(executionId: string): Promise<void> {
* (issue #286). The same backend endpoint also accepts an email-link
* token for the legacy flow; this caller relies on the bearer-session
* auth from `apiRequest` and intentionally does not pass a token in
* the URL — the backend's session-first dispatch picks the correct
* the URL -- the backend's session-first dispatch picks the correct
* auth path based on whether the session matches the
* approve-{any,own} RBAC matrix.
*/
export async function approvePurchase(executionId: string): Promise<void> {
return apiRequest<void>(`/purchases/approve/${executionId}`, { method: 'POST' });
}

/**
* Revoke a completed purchase within the provider's free-cancel window
* (issue #290). Only shown for Azure rows within the 7-day window; AWS
* and GCP providers have no direct cancel API so the button is hidden
* for those rows in the History UI.
*
* Returns the revocation result (status, revoked_at, revoked_via) or
* throws on 4xx/5xx.
*/
export interface RevokePurchaseResult {
status: string;
revoked_at: string;
revoked_via: string;
}

export async function revokePurchase(purchaseId: string): Promise<RevokePurchaseResult> {
return apiRequest<RevokePurchaseResult>(`/purchases/${purchaseId}/revoke`, { method: 'POST' });
}

/**
* Retry a failed purchase execution (issue #47).
*
Expand Down
Loading
Loading