diff --git a/frontend/src/__tests__/auth.test.ts b/frontend/src/__tests__/auth.test.ts index 102f935a1..4b030a0c9 100644 --- a/frontend/src/__tests__/auth.test.ts +++ b/frontend/src/__tests__/auth.test.ts @@ -44,6 +44,13 @@ jest.mock('../state', () => ({ import * as api from '../api'; import * as state from '../state'; +// Mock toast so alert() -> showToast() migrations (finding 11-L3) don't +// require a real DOM toast implementation in jsdom. +const mockShowToast = jest.fn(); +jest.mock('../toast', () => ({ + showToast: (...args: unknown[]) => mockShowToast(...args), +})); + // Module-level mock time that persists across tests to ensure // each test gets a time later than any previous rate limit timestamp let globalMockTime = 1000000000000; @@ -639,7 +646,8 @@ describe('Auth Module', () => { await new Promise(resolve => setTimeout(resolve, 10)); - expect(window.alert).toHaveBeenCalledWith('Please enter your current password to save changes'); + // alert() replaced by showToast() (finding 11-L3). + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Please enter your current password to save changes', kind: 'error' })); expect(api.apiRequest).not.toHaveBeenCalled(); }); @@ -815,7 +823,8 @@ describe('Auth Module', () => { expect(state.setCurrentUser).toHaveBeenCalledWith(expect.objectContaining({ email: 'newemail@example.com' })); - expect(window.alert).toHaveBeenCalledWith('Profile updated successfully'); + // alert() replaced by showToast() (finding 11-L3). + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Profile updated successfully', kind: 'success' })); }); test('save profile handles API errors', async () => { @@ -839,7 +848,8 @@ describe('Auth Module', () => { await new Promise(resolve => setTimeout(resolve, 100)); - expect(window.alert).toHaveBeenCalledWith('Failed to update profile: Update failed'); + // alert() replaced by showToast() (finding 11-L3). + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Failed to update profile: Update failed', kind: 'error' })); }); test('save profile includes new password when provided', async () => { diff --git a/frontend/src/__tests__/dashboard-ownership-950.test.ts b/frontend/src/__tests__/dashboard-ownership-950.test.ts index 335d1ae97..f69c46119 100644 --- a/frontend/src/__tests__/dashboard-ownership-950.test.ts +++ b/frontend/src/__tests__/dashboard-ownership-950.test.ts @@ -71,6 +71,7 @@ jest.mock('../utils', () => ({ getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), escapeHtml: jest.fn((str) => str || ''), populateAccountFilter: jest.fn(() => Promise.resolve()), + providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''), })); import { loadDashboard } from '../dashboard'; diff --git a/frontend/src/__tests__/dashboard.test.ts b/frontend/src/__tests__/dashboard.test.ts index 476fcdac4..43623f2da 100644 --- a/frontend/src/__tests__/dashboard.test.ts +++ b/frontend/src/__tests__/dashboard.test.ts @@ -102,10 +102,16 @@ jest.mock('../state', () => ({ // Mock utils jest.mock('../utils', () => ({ - formatCurrency: jest.fn((val) => `$${val || 0}`), + formatCurrency: jest.fn((val) => { + const n = typeof val === 'number' ? val : Number(val); + if (val == null || !Number.isFinite(n)) return '--'; + return `$${n}`; + }), getDateParts: jest.fn(() => ({ day: 15, month: 'Jan' })), escapeHtml: jest.fn((str) => str || ''), - populateAccountFilter: jest.fn(() => Promise.resolve()) + populateAccountFilter: jest.fn(() => Promise.resolve()), + // providerBadgeClass added for L4/D1 fix: whitelist-based CSS class helper. + providerBadgeClass: jest.fn((p) => ['aws', 'azure', 'gcp'].includes((p || '').toLowerCase()) ? (p || '').toLowerCase() : ''), })); import * as api from '../api'; @@ -655,11 +661,12 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Active Commitments'); expect(summary?.innerHTML).toContain('Current Coverage'); expect(summary?.innerHTML).toContain('YTD Savings'); - // Savings card must fall back to $0 (not throw or go blank). + // Savings card must fall back to '--' (not throw, go blank, or fabricate $0). const savingsCard = summary?.querySelector('.card'); expect(savingsCard?.textContent).toContain('Potential Monthly Savings'); - // mockPageLevelRange returns cellCount=0 for empty groups, so formatCurrency(0) = '$0' - expect(savingsCard?.innerHTML).toContain('$0'); + // When recs fail cellCount=0, the sentinel '--' is shown instead of fabricated $0. + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #293: summary.potential_monthly_savings is no longer the source for @@ -719,10 +726,11 @@ describe('Dashboard Module', () => { expect(summary?.innerHTML).toContain('Current Coverage'); expect(summary?.innerHTML).toContain('YTD Savings'); - // Savings card falls back to $0 when recs are absent. + // Savings card shows '--' when recs are absent (no fabricated $0). const savingsCard = summary?.querySelector('.card'); expect(savingsCard?.textContent).toContain('Potential Monthly Savings'); - expect(savingsCard?.innerHTML).toContain('$0'); + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #304: getRecommendations returns a non-array object (e.g. a wrapped @@ -747,9 +755,10 @@ describe('Dashboard Module', () => { const summary = document.getElementById('summary'); expect(summary?.innerHTML).toContain('Active Commitments'); - // Savings card falls back to $0. + // Savings card shows '--' (no fabricated $0 when recs are absent). const savingsCard = summary?.querySelector('.card'); - expect(savingsCard?.innerHTML).toContain('$0'); + expect(savingsCard?.innerHTML).toContain('--'); + expect(savingsCard?.innerHTML).not.toContain('$0'); }); // #749: the real backend always returns the envelope shape @@ -1807,4 +1816,111 @@ describe('Dashboard Module', () => { }); }); }); + + // H-3 regression: absent API coverage/count fields must render '--', not '$0'/'80%'/'0'. + // Pre-fix: `data.current_coverage || 0` fabricated '0%'; `data.target_coverage || 80` + // fabricated '80%'; `data.active_commitments || 0` fabricated '0'; the savings catch + // block fell back to formatCurrency(0). These tests must FAIL on pre-fix code. + describe('H-3: absent dashboard KPI fields render -- sentinel, not fabricated values', () => { + test('absent target_coverage shows -- not hardcoded 80%', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + // target_coverage deliberately absent + total_recommendations: 1, + active_commitments: 1, + committed_monthly: 100, + current_coverage: 60, + ytd_savings: 0, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const summary = document.getElementById('summary'); + // Must not show the hardcoded 80% fallback + expect(summary?.textContent).not.toContain('Target: 80.0%'); + expect(summary?.textContent).not.toContain('Target: 80%'); + // Must show the '--' sentinel for absent target + expect(summary?.textContent).toContain('Target: --'); + }); + + test('absent current_coverage shows -- not fabricated 0%', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + // current_coverage deliberately absent + total_recommendations: 2, + active_commitments: 1, + committed_monthly: 100, + target_coverage: 80, + ytd_savings: 0, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const summaryEl = document.getElementById('summary'); + // Find the Coverage tile specifically to check its value + const tiles = summaryEl?.querySelectorAll('.kpi-tile'); + const coverageTile = Array.from(tiles ?? []).find(t => + t.textContent?.includes('Current Coverage') + ); + expect(coverageTile).toBeDefined(); + const valueEl = coverageTile?.querySelector('.kpi-tile-value'); + // Must show '--' (not a fabricated '0.0%') when current_coverage is absent + expect(valueEl?.textContent).toBe('--'); + }); + + test('absent active_commitments shows -- not fabricated 0', async () => { + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + total_recommendations: 1, + // active_commitments deliberately absent + committed_monthly: 0, + current_coverage: 70, + target_coverage: 80, + ytd_savings: 0, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const summaryEl = document.getElementById('summary'); + // Find the Active Commitments tile specifically + const tiles = summaryEl?.querySelectorAll('.kpi-tile'); + const commitmentsTile = Array.from(tiles ?? []).find(t => + t.textContent?.includes('Active Commitments') + ); + expect(commitmentsTile).toBeDefined(); + // Must not show fabricated '0'; must show '--' + const valueEl = commitmentsTile?.querySelector('.kpi-tile-value'); + expect(valueEl?.textContent).toBe('--'); + }); + + test('savings range catch block shows -- not $0 when computation throws', async () => { + // Force the groupRecsByCell mock to throw so the catch block fires. + mockGroupRecsByCell.mockImplementationOnce(() => { throw new Error('compute error'); }); + (api.getDashboardSummary as jest.Mock).mockResolvedValue({ + total_recommendations: 1, + active_commitments: 1, + committed_monthly: 100, + current_coverage: 70, + target_coverage: 80, + ytd_savings: 0, + by_service: {} + }); + (api.getUpcomingPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadDashboard(); + + const summaryEl = document.getElementById('summary'); + const tiles = summaryEl?.querySelectorAll('.kpi-tile'); + const savingsTile = Array.from(tiles ?? []).find(t => + t.textContent?.includes('Potential Monthly Savings') + ); + expect(savingsTile).toBeDefined(); + const valueEl = savingsTile?.querySelector('.kpi-tile-value'); + // Must show '--' (not '$0') when the savings computation fails + expect(valueEl?.textContent).toBe('--'); + }); + }); }); diff --git a/frontend/src/__tests__/groups.test.ts b/frontend/src/__tests__/groups.test.ts index cd8054ea8..81f729b31 100644 --- a/frontend/src/__tests__/groups.test.ts +++ b/frontend/src/__tests__/groups.test.ts @@ -1248,3 +1248,104 @@ describe('groups/groupModals — duplicate', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Regression tests for finding 11-M3: max_amount must be validated before +// parseFloat assignment in collectPermissions. +// --------------------------------------------------------------------------- +describe('11-M3 max_amount validation in collectPermissions', () => { + // Access collectPermissions indirectly via saveGroup and observe API call. + + it('rejects Infinity max_amount (does not forward it to API)', async () => { + const modal = document.createElement('div'); + modal.id = 'group-modal'; + modal.innerHTML = ` + + + +
+
+ + + + + + +
+
+ `; + document.body.appendChild(modal); + + (api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] }); + + const event = new Event('submit', { cancelable: true }); + await groupModals.saveGroup(event); + + expect(api.createGroup).toHaveBeenCalled(); + const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; + // max_amount must not be Infinity (non-finite rejected). + const maxAmount = call.permissions[0]?.constraints?.max_amount; + expect(maxAmount).toBeUndefined(); + }); + + it('accepts a valid positive max_amount', async () => { + document.body.innerHTML = ''; + const modal = document.createElement('div'); + modal.id = 'group-modal'; + modal.innerHTML = ` + + + +
+
+ + + + + + +
+
+ `; + document.body.appendChild(modal); + + (api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] }); + + const event = new Event('submit', { cancelable: true }); + await groupModals.saveGroup(event); + + const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; + expect(call.permissions[0]?.constraints?.max_amount).toBe(5000); + }); + + it('rejects negative max_amount (does not forward it to API)', async () => { + document.body.innerHTML = ''; + const modal = document.createElement('div'); + modal.id = 'group-modal'; + modal.innerHTML = ` + + + +
+
+ + + + + + +
+
+ `; + document.body.appendChild(modal); + + (api.createGroup as jest.Mock).mockResolvedValue({ id: 'new-id', name: 'Test', description: '', permissions: [] }); + + const event = new Event('submit', { cancelable: true }); + await groupModals.saveGroup(event); + + const call = (api.createGroup as jest.Mock).mock.calls[0][0] as { permissions: Array<{ constraints?: { max_amount?: number } }> }; + // Negative value must be silently skipped. + expect(call.permissions[0]?.constraints?.max_amount).toBeUndefined(); + }); +}); diff --git a/frontend/src/__tests__/history.test.ts b/frontend/src/__tests__/history.test.ts index 9ff19ba8b..6a6cbc0f5 100644 --- a/frontend/src/__tests__/history.test.ts +++ b/frontend/src/__tests__/history.test.ts @@ -13,10 +13,18 @@ jest.mock('../navigation', () => ({ })); jest.mock('../utils', () => ({ - formatCurrency: jest.fn((val) => `$${val || 0}`), + // Mirrors the real formatCurrency behaviour: null/undefined/NaN -> '--', numbers -> '$' + formatCurrency: jest.fn((val) => (val === null || val === undefined || isNaN(val)) ? '--' : `$${val}`), 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 must encode " and ' to block attribute-boundary injection. + // This mock mirrors the real implementation so tests catch regressions where + // code reverts to the quote-transparent escapeHtml in attribute context. + escapeHtmlAttr: jest.fn((str: string | null | undefined) => { + if (!str) return ''; + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + }), populateAccountFilter: jest.fn(() => Promise.resolve()) })); @@ -439,4 +447,112 @@ describe('History Module', () => { expect(api.getHistory).toHaveBeenCalledTimes(1); }); }); + + // H-2 regression: absent API summary must render '--' on KPI cards, not '$0'. + // Pre-fix: `data.summary || {}` passed an empty HistorySummary to + // renderHistorySummary, and `?? 0` coercions made money fields render as + // '$0' (or the mock's '$0'). These tests must FAIL on the pre-fix code. + describe('H-2: absent summary renders -- sentinel on KPI cards', () => { + test('renderHistorySummary: absent summary (undefined) shows -- on all money cards (loadHistory)', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ + // summary field deliberately absent + purchases: [] + }); + + await loadHistory(); + + const summary = document.getElementById('history-summary'); + expect(summary?.innerHTML).toContain('Total Upfront Spent'); + expect(summary?.innerHTML).toContain('Monthly Savings'); + expect(summary?.innerHTML).toContain('Annual Savings'); + // Must show the absent sentinel, not a fabricated '$0' + expect(summary?.innerHTML).toContain('--'); + // The mock formatCurrency returns '$0' for 0 and '--' for null; + // verify no '$0' slips through for the money cards. + expect(summary?.textContent).not.toMatch(/Total Upfront Spent.*\$0/s); + expect(summary?.textContent).not.toMatch(/Monthly Savings.*\$0/s); + }); + + test('renderHistorySummary: absent total_upfront renders -- not $0 (viewPlanHistory)', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: { + total_purchases: 3, + // total_upfront intentionally absent + total_monthly_savings: 100, + total_annual_savings: 1200, + }, + purchases: [] + }); + + await viewPlanHistory('plan-abc'); + + const summary = document.getElementById('history-summary'); + // total_purchases is present; money fields that are absent must be '--' + expect(summary?.textContent).toContain('3'); + // formatCurrency must be called with null for the absent total_upfront + // field and must produce '--', not '$0'. This assertion fails if the + // production code regresses to passing 0 or a fabricated value. + const { formatCurrency } = jest.requireMock('../utils') as { formatCurrency: jest.Mock }; + expect(formatCurrency).toHaveBeenCalledWith(null); + // The rendered card must show '--', not a dollar amount. + expect(summary?.innerHTML).toContain('Total Upfront Spent'); + expect(summary?.innerHTML).toContain('

--

'); + expect(summary?.innerHTML).not.toContain('

$0

'); + }); + + // Regression: attribute-boundary XSS via purchase_id containing a double-quote. + // Pre-fix, data-approve-id / data-cancel-id / data-retry-id / data-execution-id + // used escapeHtml which did not encode ", allowing a crafted ID to break out of + // the attribute and inject markup. Post-fix, escapeHtmlAttr is used and " becomes + // " so the attribute stays well-formed. + test('encodes double-quotes in purchase_id for data-* attributes (XSS regression)', async () => { + const maliciousId = 'a" onmouseover="alert(1)'; + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: {}, + purchases: [ + { + purchase_id: maliciousId, + status: 'pending', + provider: 'aws', + service: 'ec2', + resource_type: 't3.medium', + region: 'us-east-1', + count: 1, + term: 1, + upfront_cost: 100, + estimated_savings: 10, + can_approve: true, + can_cancel: true, + } + ] + }); + + await loadHistory(); + + const html = document.getElementById('history-list')?.innerHTML || ''; + // The encoded form must appear in the attribute value. + expect(html).toContain('"'); + // The raw injection payload must not appear as unescaped markup. + expect(html).not.toContain('onmouseover="alert(1)"'); + }); + + test('renderHistorySummary: null summary shows unknown-state banner with all four cards', async () => { + (api.getHistory as jest.Mock).mockResolvedValue({ + summary: null as unknown as undefined, + purchases: [] + }); + + await loadHistory(); + + const summary = document.getElementById('history-summary'); + // All four cards must still render (just with '--' values). + expect(summary?.innerHTML).toContain('Total Purchases'); + expect(summary?.innerHTML).toContain('Total Upfront Spent'); + expect(summary?.innerHTML).toContain('Monthly Savings'); + expect(summary?.innerHTML).toContain('Annual Savings'); + // The unknown-state banner always shows '--' for every value. + const allDashes = (summary?.innerHTML.match(/--/g) || []).length; + expect(allDashes).toBeGreaterThanOrEqual(4); + }); + }); }); diff --git a/frontend/src/__tests__/plans.test.ts b/frontend/src/__tests__/plans.test.ts index 26ca9839c..d98f96bb8 100644 --- a/frontend/src/__tests__/plans.test.ts +++ b/frontend/src/__tests__/plans.test.ts @@ -1,7 +1,7 @@ /** * Plans module tests */ -import { loadPlans, savePlan, closePlanModal, openCreatePlanModal, openNewPlanModal, closePurchaseModal, setupPlanHandlers } from '../plans'; +import { loadPlans, savePlan, closePlanModal, openCreatePlanModal, openNewPlanModal, closePurchaseModal, setupPlanHandlers, _resetRampHandlersForTest } from '../plans'; // Mock the api module jest.mock('../api', () => ({ @@ -108,7 +108,17 @@ jest.mock('../utils', () => ({ getStatusBadge: jest.fn(() => ({ class: 'active', label: 'Active' })), escapeHtml: jest.fn((str) => str || ''), formatCurrency: jest.fn((val) => `$${val || 0}`), - populateAccountFilter: jest.fn(() => Promise.resolve()) + populateAccountFilter: jest.fn(() => Promise.resolve()), + // providerBadgeHtml added for H1 fix: returns a deterministic span so XSS + // regression tests can assert neutralisation without a real DOM escaper. + providerBadgeHtml: jest.fn((p) => { + const cls = ['aws','azure','gcp'].includes((p||'').toLowerCase()) ? (p||'').toLowerCase() : ''; + const label = (p||'').toUpperCase() + .replace(/&/g, '&').replace(//g, '>') + .replace(/"/g, '"').replace(/'/g, '''); + return `${label}`; + }), + CURRENCY_DEFAULT_DIGITS: 0, })); import * as api from '../api'; @@ -182,6 +192,12 @@ describe('Plans Module', () => { jest.clearAllMocks(); window.alert = jest.fn(); window.confirm = jest.fn().mockReturnValue(true); + // Default confirmDialog to confirmed so tests that don't override it + // behave like the old window.confirm() returning true (finding 11-L2). + mockConfirmDialog.mockResolvedValue(true); + // Reset ramp-handlers install-once guard: DOM is rebuilt each beforeEach, + // so static modal elements are fresh and need listeners re-attached (H3 fix). + _resetRampHandlersForTest(); }); describe('loadPlans', () => { @@ -870,20 +886,22 @@ describe('Plans Module', () => { test('run action executes purchase with confirmation', async () => { (api.runPlannedPurchase as jest.Mock).mockResolvedValue({}); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); - window.confirm = jest.fn().mockReturnValue(true); + // confirm() replaced by confirmDialog() (11-L2); control via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(true); const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement; runBtn?.click(); await new Promise(resolve => setTimeout(resolve, 50)); - expect(window.confirm).toHaveBeenCalled(); + expect(mockConfirmDialog).toHaveBeenCalled(); expect(api.runPlannedPurchase).toHaveBeenCalledWith('purchase-1'); expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ message: 'Purchase executed successfully' })); }); test('run action cancelled by user', async () => { - window.confirm = jest.fn().mockReturnValue(false); + // confirm() replaced by confirmDialog() (11-L2); control via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(false); const runBtn = document.querySelector('[data-action="run"]') as HTMLButtonElement; runBtn?.click(); @@ -912,7 +930,8 @@ describe('Plans Module', () => { (api.deletePlannedPurchase as jest.Mock).mockResolvedValue({}); (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); - window.confirm = jest.fn().mockReturnValue(true); + // confirm() replaced by confirmDialog() (11-L2); control via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(true); // Clear prior getPlans calls from setup so the assertion below only // counts the reload triggered by the disable action itself. @@ -923,7 +942,7 @@ describe('Plans Module', () => { await new Promise(resolve => setTimeout(resolve, 50)); - expect(window.confirm).toHaveBeenCalled(); + expect(mockConfirmDialog).toHaveBeenCalled(); expect(api.deletePlannedPurchase).toHaveBeenCalledWith('purchase-1'); // Issue #774: after disable the Plans page must refresh so the toggle // reflects the backend's new enabled=false. getPlans is the API call @@ -932,7 +951,8 @@ describe('Plans Module', () => { }); test('disable action cancelled by user', async () => { - window.confirm = jest.fn().mockReturnValue(false); + // confirm() replaced by confirmDialog() (11-L2); control via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(false); const disableBtn = document.querySelector('[data-action="disable"]') as HTMLButtonElement; disableBtn?.click(); @@ -1374,6 +1394,61 @@ describe('Plans Module', () => { ], })); }); + + // ------------------------------------------------------------------------- + // Regression tests for finding 11-M1: plan numeric fields must be validated + // with Number.isInteger(Number(raw)) before being sent to the API. + // ------------------------------------------------------------------------- + describe('11-M1: strict integer validation for plan numeric fields', () => { + test('rejects fractional target_coverage (2.5 should not truncate to 2)', async () => { + (document.getElementById('plan-coverage') as HTMLInputElement).value = '2.5'; + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + expect(api.createPlan).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + + test('rejects NaN notification_days_before (e.g. empty string after clear)', async () => { + (document.getElementById('plan-notify-days') as HTMLInputElement).value = 'abc'; + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + expect(api.createPlan).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + + test('rejects out-of-range notification_days_before (0 is below min 1)', async () => { + (document.getElementById('plan-notify-days') as HTMLInputElement).value = '0'; + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + expect(api.createPlan).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + + test('accepts valid integer values and proceeds to API call', async () => { + (api.createPlan as jest.Mock).mockResolvedValue({}); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (document.getElementById('plan-coverage') as HTMLInputElement).value = '80'; + (document.getElementById('plan-notify-days') as HTMLInputElement).value = '3'; + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + expect(api.createPlan).toHaveBeenCalledWith(expect.objectContaining({ + target_coverage: 80, + notification_days_before: 3, + })); + }); + + test('rejects fractional custom_step_percent when ramp is custom', async () => { + const customRadio = document.querySelector('input[value="custom"]') as HTMLInputElement; + customRadio.checked = true; + (document.getElementById('ramp-step-percent') as HTMLInputElement).value = '10.5'; + (document.getElementById('ramp-interval-days') as HTMLInputElement).value = '7'; + const event = { preventDefault: jest.fn() } as unknown as Event; + await savePlan(event); + expect(api.createPlan).not.toHaveBeenCalled(); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' })); + }); + }); }); describe('closePlanModal', () => { @@ -2069,7 +2144,8 @@ describe('Plans Module', () => { }); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); (api.deletePlan as jest.Mock).mockResolvedValue({}); - window.confirm = jest.fn().mockReturnValue(true); + // confirmDialog() replaces window.confirm() (11-L2); set via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(true); await loadPlans(); @@ -2143,7 +2219,8 @@ describe('Plans Module', () => { }); (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); (api.deletePlan as jest.Mock).mockRejectedValue(new Error('API Error')); - window.confirm = jest.fn().mockReturnValue(true); + // confirmDialog() replaces window.confirm() (11-L2); set via mockConfirmDialog. + mockConfirmDialog.mockResolvedValue(true); console.error = jest.fn(); await loadPlans(); @@ -2686,4 +2763,269 @@ describe('Plans Module', () => { expect(searchInput.disabled).toBe(false); }); }); + + // Regression tests for H3: setupRampScheduleHandlers must not stack duplicate + // listeners on static modal elements across multiple modal opens. + // Pre-fix: opening the plan modal N times would attach N handlers to each + // element, so a single radio change would fire updateCommitmentOptions N times. + describe('H3 regression: setupRampScheduleHandlers install-once guard', () => { + test('provider-change handler fires exactly once after opening modal 3 times', () => { + const { populateTermSelect: mockPopulate } = jest.requireMock('../commitmentOptions') as { + populateTermSelect: jest.Mock; + }; + + // beforeEach already called _resetRampHandlersForTest() so the flag is clear. + // Open the modal 3 times without resetting between them. + openNewPlanModal(); // first open: installs handlers (rampHandlersInstalled = true) + openNewPlanModal(); // second open: guard returns early -- no re-attach + openNewPlanModal(); // third open: guard returns early -- no re-attach + + mockPopulate.mockClear(); // clear any calls from setup above + + // Dispatch one change event on the provider select. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'azure'; + providerSelect.dispatchEvent(new Event('change')); + + // populateTermSelect is called exactly once from updateCommitmentOptions. + // Pre-fix (no guard) it would be called 3 times (once per open). + expect(mockPopulate).toHaveBeenCalledTimes(1); + }); + + test('ramp schedule radio handler fires exactly once after 2 modal opens', () => { + // The radio-change handler calls updatePlanNameFromSchedule which reads + // the plan-name input. Count invocations via a side-effect on populateTermSelect. + const { populateTermSelect: mockPopulate } = jest.requireMock('../commitmentOptions') as { + populateTermSelect: jest.Mock; + }; + + // beforeEach called _resetRampHandlersForTest(); open twice without reset between. + openNewPlanModal(); // installs + openNewPlanModal(); // guard: no re-install + + mockPopulate.mockClear(); + + // The provider-select change handler calls updateCommitmentOptions -> populateTermSelect. + const providerSelect = document.getElementById('plan-provider') as HTMLSelectElement; + providerSelect.value = 'gcp'; + providerSelect.dispatchEvent(new Event('change')); + + // With stacked handlers it would fire twice (once per open). + // With the guard it fires exactly once. + expect(mockPopulate).toHaveBeenCalledTimes(1); + }); + }); + + // Regression for H1: providerBadgeHtml must be used for planned-purchase rows + // and plan card detail (prevents XSS via class attribute injection). + describe('H1 regression: provider badge XSS neutralisation in plans.ts', () => { + test('providerBadgeHtml is called with the purchase provider for planned-purchase rows', async () => { + const mockBadge = (jest.requireMock('../utils') as { providerBadgeHtml: jest.Mock }).providerBadgeHtml; + + const maliciousProvider = 'aws">'; + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ + purchases: [ + { + id: 'pp-1', + plan_id: 'plan-1', + plan_name: 'Test Plan', + provider: maliciousProvider, + service: 'ec2', + resource_type: 'Standard', + region: 'us-east-1', + count: 1, + term: 1, + payment: 'no-upfront', + upfront_cost: 0, + estimated_savings: 50, + scheduled_date: '2024-06-01', + status: 'pending', + step_number: 1, + total_steps: 4, + } + ] + }); + (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] }); + + await loadPlans(); + + // providerBadgeHtml must have been called with the malicious string. + // Pre-fix the string was raw-interpolated into a class attribute via innerHTML. + // Post-fix it goes through the helper which whitelists and escapes it. + expect(mockBadge).toHaveBeenCalledWith(maliciousProvider); + }); + }); + + // H-4 regression: extractPlanInfo must not fabricate provider/term/coverage. + // Pre-fix: absent fields defaulted to 'aws'/3/80 so the card and edit modal + // showed hardcoded values. These tests must FAIL on pre-fix code. + describe('H-4: extractPlanInfo does not fabricate provider, term, or coverage', () => { + const planBase = { + id: 'plan-1', + name: 'Test Plan', + enabled: true, + auto_purchase: false, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0, current_step: 0, total_steps: 1 }, + }; + + test('absent provider in services renders -- not aws in plan card', async () => { + const mockBadge = (jest.requireMock('../utils') as { providerBadgeHtml: jest.Mock }).providerBadgeHtml; + mockBadge.mockClear(); + + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ ...planBase, services: { ec2: { service: 'ec2', term: 1, coverage: 80 } } }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadPlans(); + + // providerBadgeHtml is called with null when provider is absent. + // Pre-fix it was called with 'aws' (the hardcoded default). + expect(mockBadge).not.toHaveBeenCalledWith('aws'); + expect(mockBadge).toHaveBeenCalledWith(null); + }); + + test('absent term in services renders -- not 3yr in plan card', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ ...planBase, services: { ec2: { provider: 'aws', service: 'ec2', coverage: 80 } } }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadPlans(); + + const list = document.getElementById('plans-list'); + // Pre-fix rendered '3 Years' for a missing term; post-fix renders '--' + expect(list?.textContent).not.toContain('3 Years'); + expect(list?.textContent).toContain('--'); + }); + + test('absent coverage in services renders -- not 80% in plan card', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ ...planBase, services: { ec2: { provider: 'aws', service: 'ec2', term: 1 } } }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadPlans(); + + const list = document.getElementById('plans-list'); + // Pre-fix rendered '80%' for a missing coverage; post-fix renders '--' + expect(list?.textContent).not.toContain('80%'); + expect(list?.textContent).toContain('--'); + }); + + test('empty services object renders all -- fields in plan card', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ ...planBase, services: {} }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + + await loadPlans(); + + const list = document.getElementById('plans-list'); + // Pre-fix: all-zero fallback { provider:'aws', term:3, coverage:80 } + // Post-fix: all '--' for missing data + expect(list?.textContent).not.toContain('80%'); + expect(list?.textContent).not.toContain('3 Years'); + }); + + test('edit modal: absent term leaves select unset (not 3yr default)', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ ...planBase, id: 'plan-1', services: { ec2: { provider: 'aws', service: 'ec2', coverage: 80 } } }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (api.getPlan as jest.Mock).mockResolvedValue({ + ...planBase, + id: 'plan-1', + notification_days_before: 3, + services: { ec2: { provider: 'aws', service: 'ec2', coverage: 80 } }, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0 } + }); + + await loadPlans(); + const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; + editBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const termSelect = document.getElementById('plan-term') as HTMLSelectElement; + // Pre-fix: termSelect.value === '3' (hardcoded fallback) + // Post-fix: termSelect.value === '' (unset, user must choose) + expect(termSelect.value).not.toBe('3'); + expect(termSelect.value).toBe(''); + }); + }); + + // H-5 regression: payment option must not be auto-selected when absent. + // Pre-fix: `firstService?.payment || 'no-upfront'` pre-selected 'no-upfront' + // when payment was missing, silently changing the plan on re-save. + // These tests must FAIL on pre-fix code. + describe('H-5: absent payment leaves select blank (no no-upfront default)', () => { + const planBase = { + id: 'plan-1', + name: 'Test Plan', + enabled: true, + auto_purchase: false, + notification_days_before: 3, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0 } + }; + + test('absent payment field leaves payment select value empty in edit modal', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ + id: 'plan-1', name: 'Test Plan', enabled: true, auto_purchase: false, + services: { ec2: { provider: 'aws', service: 'ec2', term: 1, coverage: 80 } }, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0, current_step: 0, total_steps: 1 }, + }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (api.getPlan as jest.Mock).mockResolvedValue({ + ...planBase, + services: { + ec2: { + provider: 'aws', + service: 'ec2', + term: 1, + coverage: 80, + // payment deliberately absent + } + } + }); + + await loadPlans(); + const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; + editBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const paymentSelect = document.getElementById('plan-payment') as HTMLSelectElement; + // Pre-fix: paymentSelect.value === 'no-upfront' (hardcoded default) + // Post-fix: paymentSelect.value === '' (unset, user must choose) + expect(paymentSelect.value).not.toBe('no-upfront'); + expect(paymentSelect.value).toBe(''); + }); + + test('present payment field is correctly pre-selected in edit modal', async () => { + (api.getPlans as jest.Mock).mockResolvedValue({ + plans: [{ + id: 'plan-1', name: 'Test Plan', enabled: true, auto_purchase: false, + services: { ec2: { provider: 'aws', service: 'ec2', term: 1, payment: 'all-upfront', coverage: 80 } }, + ramp_schedule: { type: 'immediate', percent_per_step: 100, step_interval_days: 0, current_step: 0, total_steps: 1 }, + }] + }); + (api.getPlannedPurchases as jest.Mock).mockResolvedValue({ purchases: [] }); + (api.getPlan as jest.Mock).mockResolvedValue({ + ...planBase, + services: { + ec2: { provider: 'aws', service: 'ec2', term: 1, payment: 'all-upfront', coverage: 80 } + } + }); + + await loadPlans(); + const editBtn = document.querySelector('[data-action="edit-plan"]') as HTMLButtonElement; + editBtn?.click(); + await new Promise(resolve => setTimeout(resolve, 50)); + + const paymentSelect = document.getElementById('plan-payment') as HTMLSelectElement; + // When payment IS present, it should be correctly pre-selected + expect(paymentSelect.value).toBe('all-upfront'); + }); + }); }); diff --git a/frontend/src/__tests__/recommendations.test.ts b/frontend/src/__tests__/recommendations.test.ts index c927938a7..70e267bfb 100644 --- a/frontend/src/__tests__/recommendations.test.ts +++ b/frontend/src/__tests__/recommendations.test.ts @@ -1746,12 +1746,15 @@ describe('Recommendations Module', () => { expect(api.refreshRecommendations).toHaveBeenCalled(); }); - test('shows success alert', async () => { + test('shows success toast (alert replaced by showToast, finding 11-L3)', async () => { (api.refreshRecommendations as jest.Mock).mockResolvedValue({}); await refreshRecommendations(); - expect(window.alert).toHaveBeenCalledWith('Recommendation refresh started. This may take a few minutes.'); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Recommendation refresh started. This may take a few minutes.', + kind: 'success', + })); }); test('schedules reload after 5 seconds', async () => { @@ -1769,13 +1772,16 @@ describe('Recommendations Module', () => { expect(api.getRecommendations).toHaveBeenCalled(); }); - test('shows error on failure', async () => { + test('shows error toast on failure (alert replaced by showToast, finding 11-L3)', async () => { (api.refreshRecommendations as jest.Mock).mockRejectedValue(new Error('API Error')); console.error = jest.fn(); await refreshRecommendations(); - expect(window.alert).toHaveBeenCalledWith('Failed to start recommendation refresh'); + expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ + message: 'Failed to start recommendation refresh', + kind: 'error', + })); }); }); diff --git a/frontend/src/__tests__/setup.ts b/frontend/src/__tests__/setup.ts index 4c8824335..c52860120 100644 --- a/frontend/src/__tests__/setup.ts +++ b/frontend/src/__tests__/setup.ts @@ -57,6 +57,14 @@ Object.defineProperty(global, 'Chart', { writable: true }); +// Polyfill structuredClone for jsdom (jest-environment-jsdom does not expose +// the Node.js global structuredClone to the window scope). The utils.ts +// deepClone function delegates to structuredClone; without this the suite +// throws "ReferenceError: structuredClone is not defined" (finding 11-N1). +if (typeof globalThis.structuredClone === 'undefined') { + globalThis.structuredClone = (val: T): T => JSON.parse(JSON.stringify(val)) as T; +} + // Mock alert and confirm global.alert = jest.fn(); global.confirm = jest.fn(() => true); diff --git a/frontend/src/__tests__/users.test.ts b/frontend/src/__tests__/users.test.ts index 701f29492..0e3d78afe 100644 --- a/frontend/src/__tests__/users.test.ts +++ b/frontend/src/__tests__/users.test.ts @@ -656,6 +656,24 @@ describe('users/filters', () => { const select = document.getElementById('user-group-filter') as HTMLSelectElement; expect(select.innerHTML).toContain('<script>'); }); + + it('11-M2: escapes HTML special chars in group id (value attribute)', () => { + // Regression: group.id was injected raw into value="..." in the innerHTML + // template. An id containing quotes or angle brackets could break the + // option element boundary. Verify escapeHtml() is applied to the id too. + userState.setAvailableGroups([ + { id: '">', name: 'Evil Group', permissions: [], description: '' } + ] as any); + + userFilters.updateGroupFilterDropdown(); + + const select = document.getElementById('user-group-filter') as HTMLSelectElement; + // The raw payload must not appear verbatim inside the rendered HTML. + expect(select.innerHTML).not.toContain('"> { @@ -30,13 +34,19 @@ describe('formatCurrency', () => { expect(formatCurrency(0)).toBe('$0'); }); - test('handles null and undefined', () => { - expect(formatCurrency(null as unknown as number)).toBe('$0'); - expect(formatCurrency(undefined as unknown as number)).toBe('$0'); + test('handles null and undefined with distinct absent marker (11-N2)', () => { + // Absent/non-finite values now render as '--' to distinguish missing data + // from a real $0 balance (finding 11-N2, feedback_nullable_not_zero). + expect(formatCurrency(null as unknown as number)).toBe('--'); + expect(formatCurrency(undefined as unknown as number)).toBe('--'); }); - test('handles NaN', () => { - expect(formatCurrency(NaN)).toBe('$0'); + test('handles NaN with distinct absent marker (11-N2)', () => { + expect(formatCurrency(NaN)).toBe('--'); + }); + + test('still renders real zero as $0', () => { + expect(formatCurrency(0)).toBe('$0'); }); test('supports custom currency symbol', () => { @@ -196,7 +206,7 @@ describe('throttle', () => { describe('escapeHtml', () => { test('escapes HTML special characters', () => { - expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); + expect(escapeHtml('')).toBe('<script>alert("xss")</script>'); }); test('escapes ampersands', () => { @@ -204,7 +214,7 @@ describe('escapeHtml', () => { }); test('escapes quotes', () => { - expect(escapeHtml('"test"')).toBe('"test"'); + expect(escapeHtml('"test"')).toBe('"test"'); }); test('returns empty string for null/undefined', () => { @@ -219,6 +229,35 @@ describe('escapeHtml', () => { }); }); +describe('escapeHtmlAttr', () => { + test('encodes double-quote to " (attribute boundary safety)', () => { + // A raw " would terminate the surrounding attribute and allow markup injection. + expect(escapeHtmlAttr('"')).toBe('"'); + expect(escapeHtmlAttr('a" onmouseover="alert(1)')).toBe('a" onmouseover="alert(1)'); + }); + + test('encodes single-quote to '', () => { + expect(escapeHtmlAttr("'")).toBe('''); + expect(escapeHtmlAttr("it's")).toBe('it's'); + }); + + test('still encodes & < > (inherits from escapeHtml)', () => { + expect(escapeHtmlAttr('&')).toBe('&'); + expect(escapeHtmlAttr('')).toBe('<img>'); + expect(escapeHtmlAttr('x">')).toBe('x"><img src=x onerror=alert(1)>'); + }); + + test('returns empty string for null/undefined', () => { + expect(escapeHtmlAttr(null as unknown as string)).toBe(''); + expect(escapeHtmlAttr(undefined as unknown as string)).toBe(''); + expect(escapeHtmlAttr('')).toBe(''); + }); + + test('passes through safe strings unchanged', () => { + expect(escapeHtmlAttr('abc-123_OK')).toBe('abc-123_OK'); + }); +}); + describe('parseQueryParams', () => { test('parses query string correctly', () => { const result = parseQueryParams('?foo=bar&baz=qux'); @@ -363,3 +402,140 @@ describe('calculatePaybackMonths', () => { expect(calculatePaybackMonths(-100, 100)).toBe(0); }); }); + +// Regression tests for H1/D1/L4: providerBadgeClass whitelist. +// Pre-fix the class was interpolated raw from the API (plans.ts) or allowed any +// alphanumeric string (dashboard.ts). Post-fix all sites go through this +// helper which enforces the aws|azure|gcp allow-list. +describe('providerBadgeClass', () => { + test('returns lowercase provider for known aws', () => { + expect(providerBadgeClass('aws')).toBe('aws'); + expect(providerBadgeClass('AWS')).toBe('aws'); + }); + + test('returns lowercase provider for known azure', () => { + expect(providerBadgeClass('azure')).toBe('azure'); + expect(providerBadgeClass('Azure')).toBe('azure'); + }); + + test('returns lowercase provider for known gcp', () => { + expect(providerBadgeClass('gcp')).toBe('gcp'); + }); + + test('returns empty string for unknown provider (XSS payload neutralised)', () => { + // Regression: pre-fix this value would be injected verbatim as a CSS class. + expect(providerBadgeClass('aws">')).toBe(''); + expect(providerBadgeClass('evil-class')).toBe(''); + expect(providerBadgeClass('unknown')).toBe(''); + }); + + test('returns empty string for null/undefined/empty', () => { + expect(providerBadgeClass(null)).toBe(''); + expect(providerBadgeClass(undefined)).toBe(''); + expect(providerBadgeClass('')).toBe(''); + }); +}); + +// Regression tests for H1: providerBadgeHtml XSS in plans.ts. +// Pre-fix: plans.ts interpolated provider raw into class + textContent via innerHTML. +// Post-fix: class is whitelisted; text is HTML-escaped. +describe('providerBadgeHtml', () => { + test('renders known provider with correct class and uppercased label', () => { + const html = providerBadgeHtml('aws'); + expect(html).toContain('class="provider-badge aws"'); + expect(html).toContain('>AWS<'); + }); + + test('renders azure with correct class', () => { + const html = providerBadgeHtml('azure'); + expect(html).toContain('class="provider-badge azure"'); + expect(html).toContain('>AZURE<'); + }); + + test('neutralises XSS payload in class attribute position', () => { + // The raw value would previously be injected as: class="provider-badge " + const payload = 'aws">'; + const html = providerBadgeHtml(payload); + // Class must not contain the raw payload. + expect(html).not.toContain(payload); + // No or onerror in the output. + expect(html).not.toContain(' { + const html = providerBadgeHtml(''); + expect(html).not.toContain('