Skip to content
13 changes: 8 additions & 5 deletions frontend/src/__tests__/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,13 @@ describe('CSS Styles', () => {
expect(css).toMatch(/\.provider-badge\.gcp/);
});

test('has status badge classes', () => {
expect(css).toMatch(/\.status-badge\.active/);
expect(css).toMatch(/\.status-badge\.paused/);
expect(css).toMatch(/\.status-badge\.disabled/);
test('has one badge component with a tone per status family (#737)', () => {
for (const tone of ['success', 'warning', 'danger', 'info', 'muted']) {
expect(componentsCss).toMatch(new RegExp(`\\.badge-${tone}\\s*\\{[^}]*background:\\s*var\\(--cudly-`));
}
// The retired per-page variants must not come back.
expect(css).not.toMatch(/\.status-badge\b/);
expect(css).not.toMatch(/\.badge-(admin|user|readonly)\b/);
});

test('has toggle switch styles', () => {
Expand Down Expand Up @@ -389,7 +392,7 @@ describe('CSS File Organization', () => {
expect(components).toMatch(/button\s*\{/);
expect(components).toMatch(/button\.primary\s*\{/);
expect(components).toMatch(/\.provider-badge/);
expect(components).toMatch(/\.status-badge/);
expect(components).toMatch(/\.badge\s*\{/);
});

test('forms.css contains form elements', () => {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/plans-range-validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ jest.mock('../utils', () => ({
formatDate: jest.fn((v) => v ?? ''),
formatTerm: jest.fn((y) => `${y}yr`),
formatRampSchedule: jest.fn((v) => v ?? ''),
getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })),
getStatusBadge: jest.fn(() => ({ class: 'success', label: 'Active' })),
escapeHtml: jest.fn((s) => s ?? ''),
escapeHtmlAttr: jest.fn((str: string | null | undefined) => {
if (str == null) return '';
Expand Down
25 changes: 16 additions & 9 deletions frontend/src/__tests__/plans.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ jest.mock('../utils', () => ({
formatDate: jest.fn((val) => val ? new Date(val).toLocaleDateString() : ''),
formatTerm: jest.fn((years) => years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`),
formatRampSchedule: jest.fn((val) => val || 'Unknown'),
getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })),
getStatusBadge: jest.fn(() => ({ class: 'success', label: 'Active' })),
escapeHtml: jest.fn((str) => str || ''),
escapeHtmlAttr: jest.fn((str: string | null | undefined) => {
if (!str) return '';
Expand Down Expand Up @@ -478,7 +478,9 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('status-badge');
// getStatusBadge is mocked to the success tone; the card renders it as
// the shared .badge pill (issue #737).
expect(list?.querySelector('.plan-status .badge.badge-success')?.textContent).toBe('Active');
});

test('renders toggle switches for plans', async () => {
Expand Down Expand Up @@ -859,7 +861,7 @@ describe('Plans Module', () => {
// Paused rows stay visible with a Paused badge and are NOT replaced by
// the empty-state message.
expect(container?.innerHTML).toContain('status-paused');
expect(container?.innerHTML).toContain('>paused<');
expect(container?.querySelector('.badge.badge-muted')?.textContent).toBe('Paused');
expect(container?.innerHTML).not.toContain('No planned purchases');
});

Expand Down Expand Up @@ -3283,6 +3285,12 @@ describe('Plans Module', () => {
ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0 }
};

// The plan's own status badge (Active, success tone) shares the card, so
// assert on the health badge element rather than on the card's markup.
const healthBadge = (): Element | undefined => Array.from(
document.querySelectorAll('#plans-list .badge'),
).find((b) => (b.textContent ?? '').startsWith('Health:'));

test('renders a green badge for a healthy score (>= 80)', async () => {
(api.getPlans as jest.Mock).mockResolvedValue({
plans: [{ ...basePlan, health_score: 90, health_factors: [] }]
Expand All @@ -3292,7 +3300,7 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('badge-success');
expect(healthBadge()?.className).toBe('badge badge-success');
expect(list?.innerHTML).toContain('Health: 90');
});

Expand All @@ -3309,7 +3317,7 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('badge-warning');
expect(healthBadge()?.className).toBe('badge badge-warning');
expect(list?.innerHTML).toContain('Health: 65');
});

Expand All @@ -3329,7 +3337,7 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('badge-danger');
expect(healthBadge()?.className).toBe('badge badge-danger');
expect(list?.innerHTML).toContain('Health: 30');
});

Expand Down Expand Up @@ -3357,9 +3365,8 @@ describe('Plans Module', () => {

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain('Health: unknown');
expect(list?.innerHTML).toContain('badge-muted');
// Critically, it must NOT be dressed up as a healthy score.
expect(list?.innerHTML).not.toContain('badge-success');
expect(healthBadge()?.className).toBe('badge badge-muted');
expect(list?.innerHTML).not.toContain('Health: 100');
});

Expand All @@ -3380,7 +3387,7 @@ describe('Plans Module', () => {
await loadPlans();

const list = document.getElementById('plans-list');
expect(list?.innerHTML).toContain(expectedClass);
expect(healthBadge()?.className).toBe(`badge ${expectedClass}`);
expect(list?.innerHTML).toContain(`Health: ${score}`);
});

Expand Down
2 changes: 1 addition & 1 deletion frontend/src/__tests__/riexchange-audit-321.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('RI exchange history failure reason (A12-069)', () => {
await loadRIExchange();

const cell = statusCell();
expect(cell.querySelector('.status-badge')?.textContent).toBe('failed');
expect(cell.querySelector('.badge.badge-danger')?.textContent).toBe('Failed');
const detail = cell.querySelector('.status-detail');
expect(detail?.textContent).toBe('AcceptReservedInstancesExchangeQuote: <b>InvalidParameter</b>');
expect(detail?.querySelector('b')).toBeNull();
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/__tests__/riexchange.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,28 @@ describe('exchange history table counts (issue #286)', () => {
expect(cells).toContain('1x m5.4xlarge');
expect(cells).not.toContain('4');
});

// Issue #737: the status cell used to print the raw lowercase value with a
// page-local color (pending blue, processing orange). It now uses the shared
// badge tones and display labels.
it.each([
['pending', 'badge-warning', 'Pending'],
['processing', 'badge-warning', 'Processing'],
['completed', 'badge-success', 'Completed'],
['failed', 'badge-danger', 'Failed'],
['canceled', 'badge-muted', 'Cancelled'],
])('renders status %s as a %s badge reading %s', async (status, cls, label) => {
(api.getRIExchangeHistory as jest.Mock).mockResolvedValue([{
id: 'h1', account_id: 'a', exchange_id: 'exch-1', region: 'us-east-1', source_ri_ids: ['ri-1'],
source_instance_type: 'm5.large', source_count: 1, target_offering_id: 'o', target_instance_type: 'm5.xlarge',
target_count: 1, payment_due: '1.00', status, mode: 'manual',
created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z',
}]);
await loadRIExchange();
const badge = document.querySelector('#ri-exchange-history-list tbody .badge')!;
expect(badge.className).toBe(`badge ${cls}`);
expect(badge.textContent).toBe(label);
});
});

// Issue #1181: RIs left out of reshape analysis must be visible in the UI.
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/__tests__/status-badge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* Issue #737: one status -> tone map and one label map for every badge.
*/
import { badgeClass, badgeHtml, statusBadgeHtml, statusLabel, statusTone } from '../lib/status-badge';

describe('statusTone', () => {
test.each([
['completed', 'success'],
['approved', 'success'],
['active', 'success'],
['pending', 'warning'],
['notified', 'warning'],
['scheduled', 'warning'],
['running', 'warning'],
['processing', 'warning'],
['in_progress', 'warning'],
['partially_completed', 'warning'],
['failed', 'danger'],
['rejected', 'danger'],
['canceled', 'muted'],
['cancelled', 'muted'],
['expired', 'muted'],
['paused', 'muted'],
['disabled', 'muted'],
])('%s -> %s', (status, tone) => {
expect(statusTone(status)).toBe(tone);
});

test('pending and running share one tone on every page', () => {
// The bug: pending was blue in RI exchange and amber on Purchases; running
// was orange in one table and blue in another.
expect(statusTone('pending')).toBe(statusTone('notified'));
expect(statusTone('running')).toBe(statusTone('processing'));
});

test('is case- and whitespace-insensitive', () => {
expect(statusTone(' Completed ')).toBe('success');
expect(statusTone('FAILED')).toBe('danger');
});

test('unknown, empty and prototype-key values are muted, never success', () => {
expect(statusTone('something_new')).toBe('muted');
expect(statusTone('')).toBe('muted');
expect(statusTone(null)).toBe('muted');
expect(statusTone(undefined)).toBe('muted');
expect(statusTone('constructor')).toBe('muted');
});
});

describe('statusLabel', () => {
test.each([
['pending', 'Pending'],
['processing', 'Processing'],
['in_progress', 'In Progress'],
['partially_completed', 'Partial'],
['canceled', 'Cancelled'],
['cancelled', 'Cancelled'],
['PAUSED', 'Paused'],
['awaiting_quote', 'Awaiting quote'],
])('%s -> %s', (status, label) => {
expect(statusLabel(status)).toBe(label);
});

test('empty status reads Unknown', () => {
expect(statusLabel('')).toBe('Unknown');
expect(statusLabel(null)).toBe('Unknown');
});
});

describe('badge markup', () => {
test('badgeClass builds the shared class list', () => {
expect(badgeClass('info')).toBe('badge badge-info');
});

test('badgeHtml escapes the label and title', () => {
const html = badgeHtml('danger', '<b>x</b>', 'a "quoted" title');
expect(html).toBe('<span class="badge badge-danger" title="a &quot;quoted&quot; title">&lt;b&gt;x&lt;/b&gt;</span>');
});

test('statusBadgeHtml combines tone and label', () => {
expect(statusBadgeHtml('in_progress')).toBe('<span class="badge badge-warning">In Progress</span>');
});

test('statusBadgeHtml neutralizes markup in an unknown status', () => {
const host = document.createElement('div');
host.innerHTML = statusBadgeHtml('<img src=x onerror=alert(1)>');
expect(host.querySelector('img')).toBeNull();
expect(host.querySelector('.badge')?.className).toBe('badge badge-muted');
});
});
12 changes: 6 additions & 6 deletions frontend/src/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,21 +434,21 @@ describe('formatRampSchedule', () => {
});

describe('getStatusBadge', () => {
test('returns disabled for disabled items', () => {
test('returns the muted tone for disabled plans', () => {
const result = getStatusBadge(false, true);
expect(result.class).toBe('disabled');
expect(result.class).toBe('muted');
expect(result.label).toBe('Disabled');
});

test('returns active for enabled + auto purchase', () => {
test('returns the success tone for enabled + auto purchase', () => {
const result = getStatusBadge(true, true);
expect(result.class).toBe('active');
expect(result.class).toBe('success');
expect(result.label).toBe('Active');
});

test('returns paused for enabled without auto purchase', () => {
test('returns the info tone (not a warning) for enabled without auto purchase', () => {
const result = getStatusBadge(true, false);
expect(result.class).toBe('paused');
expect(result.class).toBe('info');
expect(result.label).toBe('Manual');
});
});
Expand Down
15 changes: 8 additions & 7 deletions frontend/src/__tests__/xss-purchase-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*
* plans.ts:renderPlannedPurchaseRow used to emit:
* <span class="status-badge …">${purchase.status}</span>
* (it now renders lib/status-badge.ts statusBadgeHtml, which escapes the label)
*
* An attacker-controlled status value such as `<script>alert(1)</script>`
* would be parsed as HTML and could execute arbitrary JavaScript. The fix
Expand Down Expand Up @@ -167,12 +168,12 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => {
// No <img> element should have been injected as a real DOM node.
expect(list!.querySelectorAll('img').length).toBe(0);
// The payload literal appears only as escaped text, not as a live attribute.
const badge = list!.querySelector('.status-badge');
const badge = list!.querySelector('.badge');
expect(badge).not.toBeNull();
expect(badge!.textContent).toContain('onerror');
});

test('valid status "pending" renders as plain text inside the status badge', async () => {
test('valid status "pending" renders as its plain-text label inside the badge', async () => {
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
purchases: [makePurchase('pending')],
});
Expand All @@ -181,12 +182,12 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => {

const list = document.getElementById('planned-purchases-list');
expect(list).not.toBeNull();
const badge = list!.querySelector('.status-badge');
const badge = list!.querySelector('.badge');
expect(badge).not.toBeNull();
expect(badge!.textContent).toBe('pending');
expect(badge!.textContent).toBe('Pending');
});

test('valid status "paused" renders as plain text inside the status badge', async () => {
test('valid status "paused" renders as its plain-text label inside the badge', async () => {
(api.getPlannedPurchases as jest.Mock).mockResolvedValue({
purchases: [makePurchase('paused')],
});
Expand All @@ -195,8 +196,8 @@ describe('XSS regression: purchase.status rendered as text node (#445)', () => {

const list = document.getElementById('planned-purchases-list');
expect(list).not.toBeNull();
const badge = list!.querySelector('.status-badge');
const badge = list!.querySelector('.badge');
expect(badge).not.toBeNull();
expect(badge!.textContent).toBe('paused');
expect(badge!.textContent).toBe('Paused');
});
});
25 changes: 16 additions & 9 deletions frontend/src/history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { renderApprovalQueueLink, wireApprovalQueueLinks } from './history-queue
import { getCurrentUser } from './state';
import { canAccess } from './permissions';
import { showSkeletonRows, teardownSkeleton } from './lib/skeleton';
import { statusBadgeHtml } from './lib/status-badge';
import { localDayRangeToUtcDays, toLocalDateInputValue } from './lib/local-date';
import { getAccountName } from './recommendations';
import { applyColumnFilters } from './lib/column-filters';
Expand Down Expand Up @@ -562,22 +563,25 @@ function renderHistorySummary(summary: HistorySummary | null): void {
`;
}

function statusBadgeHTML(status: string): string {
// historyDisplayStatus folds the purchase lifecycle onto the statuses the
// Status column shows. Tone and text then come from lib/status-badge.ts, the
// same map every other page uses (issue #737).
function historyDisplayStatus(status: string): string {
const normalized = (status || 'completed').toLowerCase();
switch (normalized) {
case 'pending':
case 'notified':
return '<span class="badge badge-warning">Pending</span>';
return 'pending';
case 'approved':
case 'running':
case 'paused':
// In-flight (issue #621): not finished — never show the green Completed
// badge for these, or the user may think the purchase is done.
return '<span class="badge badge-warning">In Progress</span>';
return 'in_progress';
case 'scheduled':
// Issue #239: a delayed purchase whose provider call has not fired
// yet — must never fall through to the default green Completed badge.
return '<span class="badge badge-warning">Scheduled</span>';
return 'scheduled';
case 'canceled':
case 'cancelled':
// Migration 000089 (expand-contract rename): the backend may return
Expand All @@ -587,21 +591,24 @@ function statusBadgeHTML(status: string): string {
// badge instead of falling through to the green Completed default.
// The CONTRACT migration (#1278) will normalize the data once the deploy
// is stable; the British branch can be removed then.
return '<span class="badge badge-muted">Cancelled</span>';
return 'cancelled';
case 'partially_completed':
// #642: some commitments succeeded, some failed. Not a clean success
// and never "failed" (real commitments exist) — a distinct warning badge
// so the user knows to read the description and reconcile the failures.
return '<span class="badge badge-warning">Partial</span>';
return 'partially_completed';
case 'failed':
return '<span class="badge badge-danger">Failed</span>';
case 'expired':
return '<span class="badge badge-muted">Expired</span>';
return normalized;
default:
return '<span class="badge badge-success">Completed</span>';
return 'completed';
}
}

function statusBadgeHTML(status: string): string {
return statusBadgeHtml(historyDisplayStatus(status));
}

function buildStatusChipRowHTML(purchases: HistoryPurchase[], active: StatusFilter): string {
const counts: Record<StatusFilter, number> = {
all: purchases.length,
Expand Down
Loading
Loading