Skip to content
Open
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
286 changes: 286 additions & 0 deletions frontend/src/__tests__/allowed-accounts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
/**
* allowed_accounts UI enforcement tests (issue #313).
*
* The backend enforces `allowed_accounts` on every API endpoint; these
* tests pin the UI surfaces that rely on that filtering:
*
* 1. Account chip in the topbar populates exclusively from the
* `listAccounts` response. When the backend returns a filtered
* subset (reflecting the user's `allowed_accounts`), only those
* accounts appear — disallowed accounts are never shown.
*
* 2. History list renders exactly the rows the API returns. The
* frontend does no client-side account filtering of its own;
* dropping that responsibility on the backend prevents the flicker
* scenario where disallowed rows briefly render before being hidden.
*
* 3. A 403 response on a mutate-by-id path (Cancel purchase) surfaces
* a user-friendly error toast instead of an unhandled exception.
*
* 4. When `listAccounts` returns an empty list (zero allowed accounts),
* the account chip collapses to the "All Accounts" sentinel only —
* no stale option from a previous session bleeds through.
*
* Related: backend enforcement tests in #307.
*/

// ---------------------------------------------------------------------------
// Shared mocks (must be declared before any imports)
// ---------------------------------------------------------------------------

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

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

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

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

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

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(() => {}),
}));

// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------

import { initTopbarFilters } from '../topbar-filters';
import { loadHistory } from '../history';
import * as api from '../api';
import * as state from '../state';
import { showToast } from '../toast';
import { confirmDialog } from '../confirmDialog';
import { getCurrentUser } from '../state';

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

const ADMIN = { id: 'admin-uuid', email: 'admin@example.com', role: 'admin' };

function setupTopbarSlot(): void {
while (document.body.firstChild) document.body.removeChild(document.body.firstChild);
const slot = document.createElement('div');
slot.id = 'topbar-filters';
document.body.appendChild(slot);
}

function setupHistoryDOM(): 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 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(mkDiv('history-summary'));
document.body.appendChild(mkDiv('history-list'));
document.body.appendChild(mkDiv('purchases-approval-queue'));
}

function makeHistoryRow(overrides: Record<string, unknown> = {}) {
return {
purchase_id: 'exec-1',
timestamp: '2024-03-01T00:00:00Z',
provider: 'aws',
service: 'ec2',
resource_type: 't3.medium',
region: 'us-east-1',
account_id: 'allowed-1',
cloud_account_id: 'allowed-1',
count: 1,
term: 1,
upfront_cost: 200,
estimated_savings: 80,
plan_name: '',
status: 'pending',
...overrides,
};
}

// ---------------------------------------------------------------------------
// 1. Account chip - allowed_accounts enforcement
// ---------------------------------------------------------------------------

describe('Account chip - allowed_accounts enforcement (issue #313)', () => {
beforeEach(() => {
jest.clearAllMocks();
setupTopbarSlot();
state.setCurrentProvider('');
state.setCurrentAccountIDs([]);
});

afterEach(() => {
state.setCurrentProvider('');
state.setCurrentAccountIDs([]);
});

test('chip lists only the accounts returned by listAccounts (backend-filtered subset)', async () => {
// Simulate backend returning only the two accounts the user is allowed
// to access (the rest are filtered server-side by allowed_accounts).
(api.listAccounts as jest.Mock).mockResolvedValue([
{ id: 'allowed-1', name: 'Prod AWS', external_id: '111111111111' },
{ id: 'allowed-2', name: 'Staging AWS', external_id: '222222222222' },
]);

initTopbarFilters();
// Drain the async populateAccountOptions call.
await new Promise((r) => setTimeout(r, 0));

// Open the account chip (second .chip-select trigger).
const triggers = document.querySelectorAll<HTMLButtonElement>('.chip-select');
const accountTrigger = triggers[1] as HTMLButtonElement;
accountTrigger.click();

const options = Array.from(
document.querySelectorAll<HTMLLIElement>('.chip-select-option'),
).map((el) => el.dataset['value']);

// "All Accounts" sentinel + exactly the two allowed accounts.
expect(options).toEqual(['', 'allowed-1', 'allowed-2']);
});

test('chip does not contain accounts absent from the listAccounts response', async () => {
(api.listAccounts as jest.Mock).mockResolvedValue([
{ id: 'allowed-only', name: 'Allowed Prod', external_id: '999999999999' },
]);

initTopbarFilters();
await new Promise((r) => setTimeout(r, 0));

const triggers = document.querySelectorAll<HTMLButtonElement>('.chip-select');
const accountTrigger = triggers[1] as HTMLButtonElement;
accountTrigger.click();

const optionValues = Array.from(
document.querySelectorAll<HTMLLIElement>('.chip-select-option'),
).map((el) => el.dataset['value']);

expect(optionValues).not.toContain('disallowed-acct');
expect(optionValues).toContain('allowed-only');
});

test('chip shows only All Accounts when listAccounts returns empty list (zero allowed accounts)', async () => {
(api.listAccounts as jest.Mock).mockResolvedValue([]);

initTopbarFilters();
await new Promise((r) => setTimeout(r, 0));

const triggers = document.querySelectorAll<HTMLButtonElement>('.chip-select');
const accountTrigger = triggers[1] as HTMLButtonElement;
accountTrigger.click();

const options = Array.from(
document.querySelectorAll<HTMLLIElement>('.chip-select-option'),
).map((el) => el.dataset['value']);

// Only the sentinel option — no stale accounts from a prior session.
expect(options).toEqual(['']);
});
});

// ---------------------------------------------------------------------------
// 2 + 3. History list and 403 on Cancel
// ---------------------------------------------------------------------------

describe('History list - allowed_accounts enforcement (issue #313)', () => {
beforeEach(() => {
setupHistoryDOM();
jest.clearAllMocks();
(getCurrentUser as jest.Mock).mockReturnValue(ADMIN);
(confirmDialog as jest.Mock).mockResolvedValue(true);
(api.listAccounts as jest.Mock).mockResolvedValue([]);
});

test('renders exactly the rows returned by getHistory (no extra client-side rows)', async () => {
// Simulate backend returning only two allowed-account rows.
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [
makeHistoryRow({ purchase_id: 'exec-allowed-1', account_id: 'allowed-1' }),
makeHistoryRow({ purchase_id: 'exec-allowed-2', account_id: 'allowed-1' }),
],
});

await loadHistory();

const list = document.getElementById('history-list')!;
// history.ts renders rows as <tr data-execution-id="..."> (see history.ts renderHistoryList).
const rows = list.querySelectorAll('tr[data-execution-id]');
// Two rows — exactly what the API returned.
expect(rows.length).toBe(2);
});

test('renders empty list when getHistory returns zero rows (all accounts disallowed)', async () => {
(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [],
});

await loadHistory();

const list = document.getElementById('history-list')!;
// No purchase rows rendered.
const rows = list.querySelectorAll('tr[data-execution-id]');
expect(rows.length).toBe(0);
});

test('403 on Cancel surfaces a user-friendly error toast, not an unhandled exception', async () => {
// Silence the expected console.error from history.ts's catch block.
const spy = jest.spyOn(console, 'error').mockImplementation(() => {});

(api.getHistory as jest.Mock).mockResolvedValue({
summary: {},
purchases: [makeHistoryRow({ purchase_id: 'exec-1', created_by_user_id: ADMIN.id })],
});

// Simulate the backend returning 403 (disallowed account).
const forbidden = new Error('Forbidden') as Error & { status?: number };
forbidden.status = 403;
(api.cancelPurchase as jest.Mock).mockRejectedValue(forbidden);

await loadHistory();

const btn = document.querySelector<HTMLButtonElement>('.history-cancel-btn');
btn?.click();
await new Promise((r) => setTimeout(r, 10));

// A toast with kind:'error' must surface — not a blank crash.
expect(showToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'error' }),
);

spy.mockRestore();
});
});
Loading