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
6 changes: 6 additions & 0 deletions frontend/src/__tests__/allowed-accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

// ---------------------------------------------------------------------------
Expand Down
125 changes: 125 additions & 0 deletions frontend/src/__tests__/approval-queue-column-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Approval Queue column-filter regression suite (issue #166).
*
* Covers the inline per-column filters wired onto the Approval Queue
* table headers. Filterable columns: Provider, Account, Service, Term,
* Payment, Created by (categorical), Count, Monthly Cost, Upfront Cost,
* Monthly Savings (numeric). Status is excluded by design — the queue
* scope is already pending|notified.
*
* Tested matrix:
* 1. Numeric expr filter narrows by predicate (monthly_cost >= N).
* 2. Categorical set filter narrows by membership (payment in set).
* 3. Multiple filters AND together across columns.
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
* 5. NaN-as-missing contract: monthly_cost == null produces NaN, which
* fails every numeric predicate (not coincidentally matches "= 0").
* 6. Clearing returns a fresh clone of the input.
*/

import { applyApprovalQueueColumnFilters } from '../history';
import type { HistoryPurchase } from '../types';
import type { ApprovalQueueColumnFilters } from '../state';

jest.mock('../api', () => ({}));
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
jest.mock('../utils', () => ({
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
formatDate: jest.fn((v) => v),
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
escapeHtml: jest.fn((s) => s ?? ''),
}));
jest.mock('../state', () => ({
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
jest.mock('../toast', () => ({ showToast: jest.fn() }));
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));

function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
return {
purchase_id: 'p',
timestamp: '2024-01-01T00:00:00Z',
provider: 'aws',
service: 'ec2',
resource_type: 'reserved-instance',
region: 'us-east-1',
count: 1,
term: 1,
payment: 'all_upfront',
upfront_cost: 100,
monthly_cost: 50,
estimated_savings: 30,
account_id: 'acct-1',
created_by_user_email: 'alice@example.com',
status: 'pending',
...overrides,
};
}

const rows: HistoryPurchase[] = [
mkRow({ purchase_id: 'a', provider: 'aws', account_id: 'acct-1', payment: 'all_upfront', monthly_cost: 50, estimated_savings: 30, created_by_user_email: 'alice@example.com' }),
mkRow({ purchase_id: 'b', provider: 'aws', account_id: 'acct-2', payment: 'no_upfront', monthly_cost: 200, estimated_savings: 100, created_by_user_email: 'bob@example.com' }),
mkRow({ purchase_id: 'c', provider: 'azure', account_id: 'acct-2', payment: 'partial_upfront', monthly_cost: null as unknown as number | undefined, estimated_savings: 60, created_by_user_email: 'alice@example.com' }),
mkRow({ purchase_id: 'd', provider: 'gcp', account_id: 'acct-3', payment: 'all_upfront', monthly_cost: 500, estimated_savings: 250, created_by_user_email: 'carol@example.com' }),
];

describe('applyApprovalQueueColumnFilters', () => {
test('numeric expr: monthly_cost >= 200 narrows to expensive rows', () => {
const filters: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '>=200' },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'd']);
});

test('categorical set: payment in {all_upfront} narrows to those rows', () => {
const filters: ApprovalQueueColumnFilters = {
payment: { kind: 'set', values: ['all_upfront'] },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'd']);
});

test('multiple filters AND together (provider=aws + created_by=alice)', () => {
const filters: ApprovalQueueColumnFilters = {
provider: { kind: 'set', values: ['aws'] },
created_by: { kind: 'set', values: ['alice@example.com'] },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a']);
});

test('invalid numeric expression is skipped (filter is a no-op)', () => {
const filters: ApprovalQueueColumnFilters = {
savings: { kind: 'expr', expr: 'not-a-num' },
};
const out = applyApprovalQueueColumnFilters(rows, filters);
expect(out).toHaveLength(rows.length);
});

test('null monthly_cost fails every numeric predicate (NaN contract)', () => {
// Row c has monthly_cost: null. A "= 0" predicate must NOT match it,
// and a ">0" predicate must NOT match it either.
const eqZero: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '0' },
};
expect(applyApprovalQueueColumnFilters(rows, eqZero).map((r) => r.purchase_id))
.not.toContain('c');

const gtZero: ApprovalQueueColumnFilters = {
monthly_cost: { kind: 'expr', expr: '>0' },
};
expect(applyApprovalQueueColumnFilters(rows, gtZero).map((r) => r.purchase_id))
.not.toContain('c');
});

test('clearing filters via empty record returns a fresh clone', () => {
const out = applyApprovalQueueColumnFilters(rows, {});
expect(out).toEqual(rows);
expect(out).not.toBe(rows);
});
});
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-approval-queue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

jest.mock('../recommendations', () => ({
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-approve-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-cancel-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-cancel-permissions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

// Mock permissions so we can inject arbitrary permission sets, including
Expand Down
117 changes: 117 additions & 0 deletions frontend/src/__tests__/history-column-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* Purchase History column-filter regression suite (issue #166).
*
* Covers the inline per-column filters wired onto the Purchase History
* table headers. The existing Status chip-row remains the canonical
* filter for status — these tests exercise the new column-filter slice
* (provider/service/type/region/term + count/upfront_cost/savings).
*
* Tested matrix:
* 1. Numeric expr filter narrows by predicate (savings > N).
* 2. Categorical set filter narrows by membership (provider in set).
* 3. Multiple filters AND together across columns.
* 4. Invalid numeric expression is skipped (no exception, no narrowing).
* 5. Clearing a filter via setPurchaseHistoryColumnFilter(col, null)
* restores the full slice.
* 6. Term column treats absent vs zero correctly — categorical-empty
* filtering.
*/

import { applyPurchaseHistoryColumnFilters } from '../history';
import type { HistoryPurchase } from '../types';
import type { PurchaseHistoryColumnFilters } from '../state';

// history.ts pulls in api/state/navigation transitively; the column-filter
// helper is pure (operates on the passed-in rows + filter record), but the
// module import path still resolves those — stub them out so the test runs
// without an apiBase / DOM context.
jest.mock('../api', () => ({}));
jest.mock('../navigation', () => ({ switchTab: jest.fn() }));
jest.mock('../utils', () => ({
formatCurrency: jest.fn((v) => `$${v ?? 0}`),
formatDate: jest.fn((v) => v),
formatTerm: jest.fn((y) => `${y} Year${y === 1 ? '' : 's'}`),
escapeHtml: jest.fn((s) => s ?? ''),
}));
jest.mock('../state', () => ({
subscribeProvider: jest.fn().mockReturnValue(() => {}),
subscribeAccount: jest.fn().mockReturnValue(() => {}),
}));
jest.mock('../confirmDialog', () => ({ confirmDialog: jest.fn() }));
jest.mock('../approval-details', () => ({ buildApprovalDetailsBody: jest.fn() }));
jest.mock('../toast', () => ({ showToast: jest.fn() }));
jest.mock('../lib/skeleton', () => ({ showSkeletonRows: jest.fn(), teardownSkeleton: jest.fn() }));
jest.mock('../recommendations', () => ({ getAccountName: jest.fn((id: string) => id) }));

function mkRow(overrides: Partial<HistoryPurchase>): HistoryPurchase {
return {
purchase_id: 'p',
timestamp: '2024-01-01T00:00:00Z',
provider: 'aws',
service: 'ec2',
resource_type: 'reserved-instance',
region: 'us-east-1',
count: 1,
term: 1,
upfront_cost: 100,
estimated_savings: 50,
...overrides,
};
}

const rows: HistoryPurchase[] = [
mkRow({ purchase_id: 'a', provider: 'aws', service: 'ec2', region: 'us-east-1', count: 1, term: 1, upfront_cost: 100, estimated_savings: 50 }),
mkRow({ purchase_id: 'b', provider: 'aws', service: 'rds', region: 'us-west-2', count: 3, term: 3, upfront_cost: 500, estimated_savings: 200 }),
mkRow({ purchase_id: 'c', provider: 'azure', service: 'ec2', region: 'eu-west-1', count: 5, term: 1, upfront_cost: 1000, estimated_savings: 400 }),
mkRow({ purchase_id: 'd', provider: 'gcp', service: 'ec2', region: 'us-east-1', count: 2, term: 1, upfront_cost: 250, estimated_savings: 80 }),
];

describe('applyPurchaseHistoryColumnFilters', () => {
test('numeric expr: savings > 100 narrows to high-saving rows', () => {
const filters: PurchaseHistoryColumnFilters = {
savings: { kind: 'expr', expr: '>100' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b', 'c']);
});

test('categorical set: provider in {aws, gcp} excludes azure', () => {
const filters: PurchaseHistoryColumnFilters = {
provider: { kind: 'set', values: ['aws', 'gcp'] },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['a', 'b', 'd']);
});

test('multiple filters AND together (provider=aws + savings >= 100)', () => {
const filters: PurchaseHistoryColumnFilters = {
provider: { kind: 'set', values: ['aws'] },
savings: { kind: 'expr', expr: '>=100' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
});

test('invalid numeric expression is skipped (filter is a no-op)', () => {
const filters: PurchaseHistoryColumnFilters = {
savings: { kind: 'expr', expr: '>>nope' },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
// Parse failure → filter ignored; full slice passes.
expect(out).toHaveLength(rows.length);
});

test('clearing filters via empty record returns a fresh clone', () => {
const out = applyPurchaseHistoryColumnFilters(rows, {});
expect(out).toEqual(rows);
expect(out).not.toBe(rows);
});

test('term filter uses categorical-set semantics with stringified values', () => {
const filters: PurchaseHistoryColumnFilters = {
term: { kind: 'set', values: ['3'] },
};
const out = applyPurchaseHistoryColumnFilters(rows, filters);
expect(out.map((r) => r.purchase_id)).toEqual(['b']);
});
});
63 changes: 63 additions & 0 deletions frontend/src/__tests__/history-filter-popover-xss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* XSS regression for renderHistoryFilterButton (issue #166 follow-up).
*
* renderHistoryFilterButton injects `column` and `label` into HTML attribute
* values (data-column, aria-label, title) via template literal. Any caller
* passing a user-controlled string without escaping would produce a stored-XSS
* vector. escapeHtmlAttr must be applied to both values before interpolation.
*/

import { renderHistoryFilterButton } from '../lib/history-filter-popover';

// Use the real escapeHtmlAttr so DOM-based escaping is exercised, not a
// pass-through stub.
jest.mock('../lib/column-filters', () => ({
parseNumericFilter: jest.fn(),
}));

describe('renderHistoryFilterButton: attribute-injection XSS guard', () => {
// This payload attempts to break out of the aria-label attribute and inject
// a script element. In raw form it would produce:
// aria-label=""><script>alert(1)</script><span x=""
const SCRIPT_PAYLOAD = '"><script>alert(1)</script><span x="';
// This payload injects an event handler attribute.
const EVENT_PAYLOAD = '" onmouseover="alert(1)" x="';

test('hostile label does not inject raw <script> tag into attribute context', () => {
const html = renderHistoryFilterButton('provider', SCRIPT_PAYLOAD, false);
// The raw < > and " chars must be entity-encoded; no unescaped angle brackets.
expect(html).not.toContain('"><script>');
expect(html).not.toContain('</script>');
// The encoded form is present (attribute value is escaped, not stripped).
expect(html).toContain('&lt;script&gt;');
});

test('hostile label does not inject event handler attribute', () => {
const html = renderHistoryFilterButton('provider', EVENT_PAYLOAD, false);
// The injected " must be encoded; no raw onmouseover= outside an attribute value.
expect(html).not.toContain('" onmouseover=');
// The encoded form must appear inside the attribute value.
expect(html).toContain('&quot; onmouseover=');
});

test('hostile column id does not inject raw markup into data-column', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const html = renderHistoryFilterButton(SCRIPT_PAYLOAD as any, 'Provider', false);
expect(html).not.toContain('><script>');
expect(html).not.toContain('</script>');
expect(html).toContain('&lt;script&gt;');
});

test('safe column and label pass through correctly', () => {
const html = renderHistoryFilterButton('provider', 'Provider', false);
expect(html).toContain('data-column="provider"');
expect(html).toContain('aria-label="Filter Provider"');
expect(html).toContain('class="history-column-filter-btn"');
});

test('active flag adds "active" class and updates aria-label', () => {
const html = renderHistoryFilterButton('savings', 'Monthly Savings', true);
expect(html).toContain('class="history-column-filter-btn active"');
expect(html).toContain('aria-label="Filter Monthly Savings - currently active"');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

import * as api from '../api';
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/__tests__/history-retry-button.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ jest.mock('../state', () => ({
getAmortizeUpfront: jest.fn().mockReturnValue(false),
setAmortizeUpfront: jest.fn(),
subscribeAmortizeUpfront: jest.fn().mockReturnValue(() => {}),
getPurchaseHistoryColumnFilters: jest.fn().mockReturnValue({}),
setPurchaseHistoryColumnFilter: jest.fn(),
clearAllPurchaseHistoryColumnFilters: jest.fn(),
getApprovalQueueColumnFilters: jest.fn().mockReturnValue({}),
setApprovalQueueColumnFilter: jest.fn(),
clearAllApprovalQueueColumnFilters: jest.fn(),
}));

import * as api from '../api';
Expand Down
Loading
Loading