Skip to content
136 changes: 136 additions & 0 deletions frontend/src/__tests__/ladder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Commitment Laddering settings module tests (issue #1333 phase 3).
*
* Covers the two security/correctness-sensitive surfaces:
* (a) renderConfigTable escapes attacker-controlled cloud_account_id/provider
* so an injected tag never lands raw in innerHTML (XSS).
* (b) saveLadderConfig's max_hourly_commit_per_run guard: blank -> null,
* positive -> passthrough, non-positive/invalid -> rejected (no store call).
*/
import { initLadderingSettings, renderConfigTable, saveLadderConfig } from '../ladder';
import type { LadderConfig } from '../api';

jest.mock('../api', () => ({
getLadderConfigs: jest.fn(),
upsertLadderConfig: jest.fn(),
getConfig: jest.fn(),
updateConfig: jest.fn(),
}));

jest.mock('../permissions', () => ({
canAccess: jest.fn(() => true),
}));

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

import * as api from '../api';

function baseConfig(overrides: Partial<LadderConfig> = {}): LadderConfig {
return {
cloud_account_id: '11111111-1111-1111-1111-111111111111',
provider: 'aws',
enabled: true,
mode: 'email_approval',
cadence: 'daily',
target_coverage: 100,
buffer_fraction: 0.1,
baseline_percentile: 5,
lookback_days: 30,
buffer_utilization_threshold: 90,
max_hourly_commit_per_run: null,
max_actions_per_run: 10,
ramp_schedule: { steps: [{ after_days: 0, fraction: 1.0 }] },
updated_at: '2026-01-15T10:00:00Z',
...overrides,
} as LadderConfig;
}

// Render the full section (incl. the modal form) into the DOM so the exported
// helpers have the elements they read.
async function renderSection(): Promise<void> {
document.body.innerHTML = '<div id="commitment-laddering-settings"></div>';
(api.getLadderConfigs as jest.Mock).mockResolvedValue([]);
await initLadderingSettings(false);
}

describe('ladder.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowToast.mockReset();
});

describe('renderConfigTable XSS escaping', () => {
test('renders a malicious cloud_account_id/provider as inert text, not markup', async () => {
await renderSection();
const evil = '<img src=x onerror=alert(1)>';
renderConfigTable([baseConfig({ cloud_account_id: evil, provider: evil })]);

const container = document.getElementById('ladder-configs-table-container')!;

// The injected payload must not create a live element anywhere in the
// table (DOM-level assertion; reading serialized innerHTML is unreliable
// because jsdom re-serializes attribute values with raw </> which are
// inert inside a quoted attribute).
expect(container.querySelector('img')).toBeNull();

// The account-id cell holds the payload as TEXT (no child elements =>
// it was escaped, not parsed as HTML).
const firstCell = container.querySelector('tbody tr td');
expect(firstCell?.textContent).toBe(evil);
expect(firstCell?.children.length).toBe(0);

// Exactly one edit button exists (no extra nodes injected) and it carries
// the value as an inert data-* attribute rather than as markup.
const btns = container.querySelectorAll<HTMLButtonElement>('button.ladder-edit-btn');
expect(btns.length).toBe(1);
expect(btns[0]!.dataset['provider']).toBe(evil);
});
});

describe('saveLadderConfig max_hourly guard', () => {
// Fill the modal form with a valid baseline so save reaches the store,
// then override max-hourly per case.
async function primeValidForm(maxHourly: string): Promise<void> {
await renderSection();
(document.getElementById('ladder-cfg-account') as HTMLInputElement).value = 'acct-1';
(document.getElementById('ladder-cfg-ramp-schedule') as HTMLTextAreaElement).value =
'{"steps":[{"after_days":0,"fraction":1.0}]}';
(document.getElementById('ladder-cfg-max-hourly') as HTMLInputElement).value = maxHourly;
}

test('blank max-hourly serializes to null in the payload', async () => {
await primeValidForm('');
(api.upsertLadderConfig as jest.Mock).mockResolvedValue(baseConfig());

await saveLadderConfig();

expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1);
const sent = (api.upsertLadderConfig as jest.Mock).mock.calls[0][0] as LadderConfig;
expect(sent.max_hourly_commit_per_run).toBeNull();
});

test('positive max-hourly passes through unchanged', async () => {
await primeValidForm('5');
(api.upsertLadderConfig as jest.Mock).mockResolvedValue(baseConfig());

await saveLadderConfig();

expect(api.upsertLadderConfig).toHaveBeenCalledTimes(1);
const sent = (api.upsertLadderConfig as jest.Mock).mock.calls[0][0] as LadderConfig;
expect(sent.max_hourly_commit_per_run).toBe(5);
});

test('non-positive max-hourly is rejected (guarded, no store call)', async () => {
await primeValidForm('-1');
await saveLadderConfig();

expect(api.upsertLadderConfig).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: 'error' }),
);
});
});
});
4 changes: 4 additions & 0 deletions frontend/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@ export {
approveRIExchange
} from './riexchange';

// Re-export commitment-laddering functions and types (issue #1336)
export type { LadderConfig, LadderRampStep } from './ladder';
export { getLadderConfigs, upsertLadderConfig } from './ladder';

// Re-export registrations functions and types
export type { AccountRegistration } from './registrations';
export {
Expand Down
74 changes: 74 additions & 0 deletions frontend/src/api/ladder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Commitment Laddering API functions (issue #1333 phase 3).
*
* The feature is flag-gated default-off: the global kill-switch
* (global_config.laddering_enabled) must be true AND the per-account
* LadderConfig.enabled must be true before any laddering engine run fires.
*/

import { apiRequest } from './client';

/**
* A single ramp step within a ladder ramp schedule.
* AfterDays is the delay from run start; Fraction is the share of the
* total target allocated by this tranche (fractions must sum to 1.0).
*/
export interface LadderRampStep {
after_days: number;
fraction: number;
}

/**
* Per-account, per-provider ladder configuration.
*
* Mode controls whether runs require human approval before executing:
* email_approval - sends an approval email; purchases fire only after approval
* auto_approve - purchases fire immediately (no human gate)
*
* Cadence controls how often the engine runs:
* daily - once per day
* weekly - once per week
*
* All numeric money fields use number|null rather than 0 so absent/unconfigured
* values are distinguishable from a deliberately configured $0.
*/
export interface LadderConfig {
id?: string;
cloud_account_id: string;
provider: string;
enabled: boolean;
mode: 'email_approval' | 'auto_approve';
cadence: 'daily' | 'weekly';
target_coverage: number;
buffer_fraction: number;
baseline_percentile: number;
lookback_days: number;
buffer_utilization_threshold: number;
/** null = no cap on hourly commitment delta per run */
max_hourly_commit_per_run: number | null;
max_actions_per_run: number;
ramp_schedule: { steps: LadderRampStep[] };
created_at?: string;
updated_at?: string;
}

/**
* List all per-account ladder configurations.
* Requires view:config permission.
*/
export async function getLadderConfigs(): Promise<LadderConfig[]> {
const resp = await apiRequest<{ configs: LadderConfig[] }>('/ladder/configs');
return resp.configs ?? [];
}

/**
* Upsert (insert or update) a per-account ladder configuration.
* The upsert key is (cloud_account_id, provider).
* Requires update:config permission.
*/
export async function upsertLadderConfig(cfg: LadderConfig): Promise<LadderConfig> {
return apiRequest<LadderConfig>('/ladder/configs', {
method: 'PUT',
body: JSON.stringify(cfg),
});
}
9 changes: 7 additions & 2 deletions frontend/src/api/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,14 @@ export async function updateServiceConfig(provider: string, service: string, cfg
}

/**
* Update global configuration
* Update global configuration.
*
* Accepts a partial config: the backend merges the body over the stored config
* (any key absent from the JSON keeps its persisted value), so callers may send
* only the fields they intend to change (e.g. the laddering kill-switch toggle
* sends just { laddering_enabled }). A full Config is still valid input.
*/
export async function updateConfig(config: Config): Promise<Config> {
export async function updateConfig(config: Partial<Config>): Promise<Config> {
return apiRequest<Config>('/config', {
method: 'PUT',
body: JSON.stringify(config)
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,10 @@ export interface Config {
// Must be 7, 30, or 60 (AWS LookbackPeriodInDays enum). Default: 7.
// GCP CUD Recommender has no equivalent parameter; applies to AWS only.
recommendations_lookback_days?: number;
// Global kill-switch for the commitment-laddering feature (issue #1336).
// Default false. When true, per-account LadderConfig.enabled settings
// determine whether the engine runs for that account.
laddering_enabled?: boolean;
}

export interface ServiceConfig {
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,9 @@ <h5>Cloud Storage</h5>
<!-- RI Exchange Automation Settings (loaded dynamically) -->
<div id="ri-exchange-automation-settings"></div>

<!-- Commitment Laddering Settings (flag-gated default-off, loaded dynamically) -->
<div id="commitment-laddering-settings"></div>

<div class="settings-buttons">
<button type="button" id="reset-purchasing-btn" class="btn btn-destructive">Reset to Defaults</button>
<button type="button" id="save-purchasing-btn" class="btn btn-primary">Save Settings</button>
Expand Down
Loading
Loading