Skip to content

Commit 25525bb

Browse files
committed
feat(ladder): add per-account config + schema foundation (default-off)
Foundation for commitment laddering (phase-3 PR-1). Flag-gated and default-off: nothing runs until an operator enables both the global kill-switch and a per-account config. No scheduled task, email, or execution path is wired here (those land in later phase-3 PRs). Surface: - Migrations 000079/080/081: ladder_configs (per account x provider), ladder_runs (immutable audit), ladder_tranches (timed purchase slices with a run_id trace to ladder_runs); global_config.laddering_enabled kill-switch; ladder_run_id FK on purchase_executions and ri_exchange_history. All reversible (up/down verified on PG16). - Config store: GetLadderConfigs / GetLadderConfig / UpsertLadderConfig plus StoreInterface widening and mock coverage. - API: GET/PUT /api/ladder/configs with RBAC and fail-loud validation (out-of-range numerics rejected 400, never silently defaulted; absent keys default, explicit buffer_fraction=0 honored as no-buffer). - Settings UI: Commitment Laddering section (global toggle + per-account editor) rendered into the Purchasing panel. Money-path fields are nullable (NULL, not 0) and typed enums are validated against pkg/ladder rather than redefined. Part of #1336 (phase-3 PR-1) Refs #1333
1 parent 60526ce commit 25525bb

29 files changed

Lines changed: 1983 additions & 4 deletions
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
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+
});

frontend/src/api/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,10 @@ export {
200200
approveRIExchange
201201
} from './riexchange';
202202

203+
// Re-export commitment-laddering functions and types (issue #1336)
204+
export type { LadderConfig, LadderRampStep } from './ladder';
205+
export { getLadderConfigs, upsertLadderConfig } from './ladder';
206+
203207
// Re-export registrations functions and types
204208
export type { AccountRegistration } from './registrations';
205209
export {

frontend/src/api/ladder.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Commitment Laddering API functions (issue #1333 phase 3).
3+
*
4+
* The feature is flag-gated default-off: the global kill-switch
5+
* (global_config.laddering_enabled) must be true AND the per-account
6+
* LadderConfig.enabled must be true before any laddering engine run fires.
7+
*/
8+
9+
import { apiRequest } from './client';
10+
11+
/**
12+
* A single ramp step within a ladder ramp schedule.
13+
* AfterDays is the delay from run start; Fraction is the share of the
14+
* total target allocated by this tranche (fractions must sum to 1.0).
15+
*/
16+
export interface LadderRampStep {
17+
after_days: number;
18+
fraction: number;
19+
}
20+
21+
/**
22+
* Per-account, per-provider ladder configuration.
23+
*
24+
* Mode controls whether runs require human approval before executing:
25+
* email_approval - sends an approval email; purchases fire only after approval
26+
* auto_approve - purchases fire immediately (no human gate)
27+
*
28+
* Cadence controls how often the engine runs:
29+
* daily - once per day
30+
* weekly - once per week
31+
*
32+
* All numeric money fields use number|null rather than 0 so absent/unconfigured
33+
* values are distinguishable from a deliberately configured $0.
34+
*/
35+
export interface LadderConfig {
36+
id?: string;
37+
cloud_account_id: string;
38+
provider: string;
39+
enabled: boolean;
40+
mode: 'email_approval' | 'auto_approve';
41+
cadence: 'daily' | 'weekly';
42+
target_coverage: number;
43+
buffer_fraction: number;
44+
baseline_percentile: number;
45+
lookback_days: number;
46+
buffer_utilization_threshold: number;
47+
/** null = no cap on hourly commitment delta per run */
48+
max_hourly_commit_per_run: number | null;
49+
max_actions_per_run: number;
50+
ramp_schedule: { steps: LadderRampStep[] };
51+
created_at?: string;
52+
updated_at?: string;
53+
}
54+
55+
/**
56+
* List all per-account ladder configurations.
57+
* Requires view:config permission.
58+
*/
59+
export async function getLadderConfigs(): Promise<LadderConfig[]> {
60+
const resp = await apiRequest<{ configs: LadderConfig[] }>('/ladder/configs');
61+
return resp.configs ?? [];
62+
}
63+
64+
/**
65+
* Upsert (insert or update) a per-account ladder configuration.
66+
* The upsert key is (cloud_account_id, provider).
67+
* Requires update:config permission.
68+
*/
69+
export async function upsertLadderConfig(cfg: LadderConfig): Promise<LadderConfig> {
70+
return apiRequest<LadderConfig>('/ladder/configs', {
71+
method: 'PUT',
72+
body: JSON.stringify(cfg),
73+
});
74+
}

frontend/src/api/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,10 @@ export interface Config {
304304
// Must be 7, 30, or 60 (AWS LookbackPeriodInDays enum). Default: 7.
305305
// GCP CUD Recommender has no equivalent parameter; applies to AWS only.
306306
recommendations_lookback_days?: number;
307+
// Global kill-switch for the commitment-laddering feature (issue #1336).
308+
// Default false. When true, per-account LadderConfig.enabled settings
309+
// determine whether the engine runs for that account.
310+
laddering_enabled?: boolean;
307311
}
308312

309313
export interface ServiceConfig {

frontend/src/index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,9 @@ <h5>Cloud Storage</h5>
630630
<!-- RI Exchange Automation Settings (loaded dynamically) -->
631631
<div id="ri-exchange-automation-settings"></div>
632632

633+
<!-- Commitment Laddering Settings (flag-gated default-off, loaded dynamically) -->
634+
<div id="commitment-laddering-settings"></div>
635+
633636
<div class="settings-buttons">
634637
<button type="button" id="reset-purchasing-btn" class="btn btn-destructive">Reset to Defaults</button>
635638
<button type="button" id="save-purchasing-btn" class="btn btn-primary">Save Settings</button>

0 commit comments

Comments
 (0)