|
| 1 | +/** |
| 2 | + * Commitment Laddering settings module tests (issue #1333 phase 3). |
| 3 | + * |
| 4 | + * Covers the two security/correctness-sensitive surfaces: |
| 5 | + * (a) renderConfigTable escapes attacker-controlled cloud_account_id/provider |
| 6 | + * so an injected tag never lands raw in innerHTML (XSS). |
| 7 | + * (b) saveLadderConfig's max_hourly_commit_per_run guard: blank -> null, |
| 8 | + * positive -> passthrough, non-positive/invalid -> rejected (no store call). |
| 9 | + */ |
| 10 | +import { initLadderingSettings, renderConfigTable, saveLadderConfig } from '../ladder'; |
| 11 | +import type { LadderConfig } from '../api'; |
| 12 | + |
| 13 | +jest.mock('../api', () => ({ |
| 14 | + getLadderConfigs: jest.fn(), |
| 15 | + upsertLadderConfig: jest.fn(), |
| 16 | + getConfig: jest.fn(), |
| 17 | + updateConfig: jest.fn(), |
| 18 | +})); |
| 19 | + |
| 20 | +jest.mock('../permissions', () => ({ |
| 21 | + canAccess: jest.fn(() => true), |
| 22 | +})); |
| 23 | + |
| 24 | +const mockShowToast = jest.fn(); |
| 25 | +jest.mock('../toast', () => ({ |
| 26 | + showToast: (opts: unknown) => mockShowToast(opts), |
| 27 | +})); |
| 28 | + |
| 29 | +import * as api from '../api'; |
| 30 | + |
| 31 | +function baseConfig(overrides: Partial<LadderConfig> = {}): LadderConfig { |
| 32 | + return { |
| 33 | + cloud_account_id: '11111111-1111-1111-1111-111111111111', |
| 34 | + provider: 'aws', |
| 35 | + enabled: true, |
| 36 | + mode: 'email_approval', |
| 37 | + cadence: 'daily', |
| 38 | + target_coverage: 100, |
| 39 | + buffer_fraction: 0.1, |
| 40 | + baseline_percentile: 5, |
| 41 | + lookback_days: 30, |
| 42 | + buffer_utilization_threshold: 90, |
| 43 | + max_hourly_commit_per_run: null, |
| 44 | + max_actions_per_run: 10, |
| 45 | + ramp_schedule: { steps: [{ after_days: 0, fraction: 1.0 }] }, |
| 46 | + updated_at: '2026-01-15T10:00:00Z', |
| 47 | + ...overrides, |
| 48 | + } as LadderConfig; |
| 49 | +} |
| 50 | + |
| 51 | +// Render the full section (incl. the modal form) into the DOM so the exported |
| 52 | +// helpers have the elements they read. |
| 53 | +async function renderSection(): Promise<void> { |
| 54 | + document.body.innerHTML = '<div id="commitment-laddering-settings"></div>'; |
| 55 | + (api.getLadderConfigs as jest.Mock).mockResolvedValue([]); |
| 56 | + await initLadderingSettings(false); |
| 57 | +} |
| 58 | + |
| 59 | +describe('ladder.ts', () => { |
| 60 | + beforeEach(() => { |
| 61 | + jest.clearAllMocks(); |
| 62 | + mockShowToast.mockReset(); |
| 63 | + }); |
| 64 | + |
| 65 | + describe('renderConfigTable XSS escaping', () => { |
| 66 | + test('renders a malicious cloud_account_id/provider as inert text, not markup', async () => { |
| 67 | + await renderSection(); |
| 68 | + const evil = '<img src=x onerror=alert(1)>'; |
| 69 | + renderConfigTable([baseConfig({ cloud_account_id: evil, provider: evil })]); |
| 70 | + |
| 71 | + const container = document.getElementById('ladder-configs-table-container')!; |
| 72 | + |
| 73 | + // The injected payload must not create a live element anywhere in the |
| 74 | + // table (DOM-level assertion; reading serialized innerHTML is unreliable |
| 75 | + // because jsdom re-serializes attribute values with raw </> which are |
| 76 | + // inert inside a quoted attribute). |
| 77 | + expect(container.querySelector('img')).toBeNull(); |
| 78 | + |
| 79 | + // The account-id cell holds the payload as TEXT (no child elements => |
| 80 | + // it was escaped, not parsed as HTML). |
| 81 | + const firstCell = container.querySelector('tbody tr td'); |
| 82 | + expect(firstCell?.textContent).toBe(evil); |
| 83 | + expect(firstCell?.children.length).toBe(0); |
| 84 | + |
| 85 | + // Exactly one edit button exists (no extra nodes injected) and it carries |
| 86 | + // the value as an inert data-* attribute rather than as markup. |
| 87 | + const btns = container.querySelectorAll<HTMLButtonElement>('button.ladder-edit-btn'); |
| 88 | + expect(btns.length).toBe(1); |
| 89 | + expect(btns[0]!.dataset['provider']).toBe(evil); |
| 90 | + }); |
| 91 | + }); |
| 92 | + |
| 93 | + describe('saveLadderConfig max_hourly guard', () => { |
| 94 | + // Fill the modal form with a valid baseline so save reaches the store, |
| 95 | + // then override max-hourly per case. |
| 96 | + async function primeValidForm(maxHourly: string): Promise<void> { |
| 97 | + await renderSection(); |
| 98 | + (document.getElementById('ladder-cfg-account') as HTMLInputElement).value = 'acct-1'; |
| 99 | + (document.getElementById('ladder-cfg-ramp-schedule') as HTMLTextAreaElement).value = |
| 100 | + '{"steps":[{"after_days":0,"fraction":1.0}]}'; |
| 101 | + (document.getElementById('ladder-cfg-max-hourly') as HTMLInputElement).value = maxHourly; |
| 102 | + } |
| 103 | + |
| 104 | + test('blank max-hourly serializes to null in the payload', async () => { |
| 105 | + await primeValidForm(''); |
| 106 | + (api.upsertLadderConfig as jest.Mock).mockResolvedValue(baseConfig()); |
| 107 | + |
| 108 | + await saveLadderConfig(); |
| 109 | + |
| 110 | + expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1); |
| 111 | + const sent = (api.upsertLadderConfig as jest.Mock).mock.calls[0][0] as LadderConfig; |
| 112 | + expect(sent.max_hourly_commit_per_run).toBeNull(); |
| 113 | + }); |
| 114 | + |
| 115 | + test('positive max-hourly passes through unchanged', async () => { |
| 116 | + await primeValidForm('5'); |
| 117 | + (api.upsertLadderConfig as jest.Mock).mockResolvedValue(baseConfig()); |
| 118 | + |
| 119 | + await saveLadderConfig(); |
| 120 | + |
| 121 | + expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1); |
| 122 | + const sent = (api.upsertLadderConfig as jest.Mock).mock.calls[0][0] as LadderConfig; |
| 123 | + expect(sent.max_hourly_commit_per_run).toBe(5); |
| 124 | + }); |
| 125 | + |
| 126 | + test('non-positive max-hourly is rejected (guarded, no store call)', async () => { |
| 127 | + await primeValidForm('-1'); |
| 128 | + await saveLadderConfig(); |
| 129 | + |
| 130 | + expect(api.upsertLadderConfig).not.toHaveBeenCalled(); |
| 131 | + expect(mockShowToast).toHaveBeenCalledWith( |
| 132 | + expect.objectContaining({ kind: 'error' }), |
| 133 | + ); |
| 134 | + }); |
| 135 | + }); |
| 136 | +}); |
0 commit comments