Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
142 changes: 140 additions & 2 deletions frontend/src/__tests__/ladder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
import { initLadderingSettings, renderConfigTable, saveLadderConfig } from '../ladder';
import type { LadderConfig } from '../api';
import { waitFor } from '@testing-library/dom';
import { listAccountsMinimal, type AccountSummary } from '../api/accounts';
import { canAccess } from '../permissions';

jest.mock('../api/accounts', () => ({ listAccountsMinimal: jest.fn() }));
jest.mock('../ladder-timeline', () => ({ initLadderTimeline: jest.fn() }));

jest.mock('../api', () => ({
getLadderConfigs: jest.fn(),
Expand Down Expand Up @@ -61,6 +66,12 @@ describe('ladder.ts', () => {
beforeEach(() => {
jest.clearAllMocks();
mockShowToast.mockReset();
(canAccess as jest.Mock).mockReturnValue(true);
(listAccountsMinimal as jest.Mock).mockResolvedValue([
{ id: 'acct-1', name: 'AWS account', external_id: 'aws-external', provider: 'aws' },
{ id: 'azure-id', name: 'Azure account', external_id: 'azure-external', provider: 'azure' },
{ id: 'gcp-id', name: 'GCP account', external_id: 'gcp-external', provider: 'gcp' },
]);
});

test('lookback input advertises the 10-day baseline minimum', async () => {
Expand All @@ -82,7 +93,7 @@ describe('ladder.ts', () => {
expect(document.getElementById('ladder-cfg-mode')).toHaveValue(mode);
expect(document.getElementById('ladder-cfg-cadence')).toHaveValue(cadence);
expect(document.getElementById('ladder-cfg-provider')).toBeDisabled();
expect(document.getElementById('ladder-cfg-account')).toHaveAttribute('readonly');
expect(document.getElementById('ladder-cfg-account')).toBeDisabled();

(document.getElementById('ladder-cfg-target-coverage') as HTMLInputElement).value = '75';
document.getElementById('ladder-config-form')!.dispatchEvent(new Event('submit', { cancelable: true }));
Expand Down Expand Up @@ -197,7 +208,9 @@ describe('ladder.ts', () => {
// 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-add-config-btn')!.click();
await waitFor(() => expect(document.getElementById('ladder-cfg-account')).toBeEnabled());
(document.getElementById('ladder-cfg-account') as HTMLSelectElement).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;
Expand Down Expand Up @@ -235,4 +248,129 @@ describe('ladder.ts', () => {
);
});
});

describe('registered account selector', () => {
const account = (): HTMLSelectElement => document.getElementById('ladder-cfg-account') as HTMLSelectElement;
const provider = (): HTMLSelectElement => document.getElementById('ladder-cfg-provider') as HTMLSelectElement;
const status = (): HTMLElement => document.getElementById('ladder-cfg-account-status')!;
const saveButton = (): HTMLElement => document.getElementById('ladder-config-save-btn')!;
const changeProvider = (value: string): void => { provider().value = value; provider().dispatchEvent(new Event('change')); };
async function open(): Promise<void> {
await renderSection();
document.getElementById('ladder-add-config-btn')!.click();
}
async function ready(): Promise<void> { await waitFor(() => expect(account()).toBeEnabled()); }

test('offers only registered accounts for the chosen provider and submits their internal ID', async () => {
await open();
await ready();
expect(account().tagName).toBe('SELECT');
expect(account()).toHaveValue('');
expect(Array.from(account().options).map(option => option.value)).toEqual(['', 'acct-1']);
expect(account().options[1]!.textContent).toBe('AWS account (aws-external)');
account().value = 'acct-1';
changeProvider('azure');
expect(account()).toHaveValue('');
expect(Array.from(account().options).map(option => option.value)).toEqual(['', 'azure-id']);
changeProvider('gcp');
expect(Array.from(account().options).map(option => option.value)).toEqual(['', 'gcp-id']);
account().value = 'gcp-id';
(api.upsertLadderConfig as jest.Mock).mockImplementation(async config => config);
await saveLadderConfig();
expect(api.upsertLadderConfig).toHaveBeenCalledWith(expect.objectContaining({ cloud_account_id: 'gcp-id', provider: 'gcp' }));
expect(listAccountsMinimal).toHaveBeenCalledWith();
expect(api.updateConfig).not.toHaveBeenCalled();
});

test.each(['invented-id', 'azure-id'])('rejects an injected option %s for AWS', async id => {
await open();
await ready();
if (account() instanceof HTMLSelectElement) account().add(new Option('Injected', id));
account().value = id;
(api.upsertLadderConfig as jest.Mock).mockImplementation(async config => config);
await saveLadderConfig();
expect(api.upsertLadderConfig).not.toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith(expect.objectContaining({ kind: 'error' }));
});

test('renders hostile names and external IDs as text', async () => {
const evil = '<img src=x onerror=alert(1)>';
(listAccountsMinimal as jest.Mock).mockResolvedValue([{ id: 'safe-id', name: evil, external_id: evil, provider: 'aws' }]);
await open();
await ready();
expect(account().options[1]!.textContent).toBe(`${evil} (${evil})`);
expect(document.querySelector('#ladder-config-form img')).toBeNull();
});

test('blocks save while loading and filters the provider selected before response', async () => {
let resolve!: (accounts: AccountSummary[]) => void;
(listAccountsMinimal as jest.Mock).mockReturnValue(new Promise<AccountSummary[]>(done => { resolve = done; }));
await open();
expect(account()).toBeDisabled();
expect(saveButton()).toBeDisabled();
expect(status()).toHaveTextContent(/loading/i);
await saveLadderConfig();
expect(api.upsertLadderConfig).not.toHaveBeenCalled();
changeProvider('gcp');
resolve([{ id: 'gcp-late', name: 'Late', external_id: 'external', provider: 'gcp' }]);
await ready();
expect(Array.from(account().options).map(option => option.value)).toEqual(['', 'gcp-late']);
});

test('a response from a closed modal cannot replace a reopened form', async () => {
let resolve!: (accounts: AccountSummary[]) => void;
(listAccountsMinimal as jest.Mock).mockReturnValueOnce(new Promise<AccountSummary[]>(done => { resolve = done; }));
await open();
document.getElementById('ladder-modal-cancel-btn')!.click();
document.getElementById('ladder-add-config-btn')!.click();
await ready();
account().value = 'acct-1';
resolve([{ id: 'stale-id', name: 'Stale', external_id: 'stale', provider: 'aws' }]);
await Promise.resolve();
expect(account()).toHaveValue('acct-1');
expect(Array.from(account().options).map(option => option.value)).toEqual(['', 'acct-1']);
});

test.each(['empty', 'error'])('blocks creation when account list is %s', async state => {
if (state === 'error') (listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('Forbidden'));
else (listAccountsMinimal as jest.Mock).mockResolvedValue([]);
await open();
await waitFor(() => expect(status()).toHaveTextContent(state === 'error' ? /could not load/i : /no registered/i));
expect(account()).toBeDisabled();
expect(saveButton()).toBeDisabled();
account().add(new Option('Injected', 'invented-id'));
account().value = 'invented-id';
await saveLadderConfig();
expect(api.upsertLadderConfig).not.toHaveBeenCalled();
});

test.each(['empty', 'error'])('keeps the captured edit key with a visible warning when list is %s', async state => {
if (state === 'error') (listAccountsMinimal as jest.Mock).mockRejectedValue(new Error('Forbidden'));
else (listAccountsMinimal as jest.Mock).mockResolvedValue([]);
const config = baseConfig({ id: 'existing', cloud_account_id: 'saved-id', provider: 'azure' });
await renderSection([config]);
document.querySelector<HTMLButtonElement>('.ladder-edit-btn')!.click();
await waitFor(() => expect(status()).toHaveTextContent(/identity is unchanged/i));
expect(account()).toBeDisabled();
expect(provider()).toBeDisabled();
account().add(new Option('Injected', 'different-id'));
account().value = 'different-id';
provider().value = 'aws';
(api.upsertLadderConfig as jest.Mock).mockImplementation(async saved => saved);
await saveLadderConfig();
expect(api.upsertLadderConfig).toHaveBeenCalledWith(expect.objectContaining({ cloud_account_id: 'saved-id', provider: 'azure' }));
});

test('permission loss prevents submitting a selected account', async () => {
await open();
await ready();
account().value = 'acct-1';
(canAccess as jest.Mock).mockReturnValue(false);
await saveLadderConfig();
expect(api.upsertLadderConfig).not.toHaveBeenCalled();
await renderSection();
expect(document.getElementById('ladder-add-config-btn')).toBeNull();
expect(listAccountsMinimal).toHaveBeenCalledTimes(1);
});
});
});
55 changes: 55 additions & 0 deletions frontend/src/ladder-account-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { listAccountsMinimal, type AccountSummary } from './api/accounts';
import type { LadderConfig } from './api/ladder';

type AccountIdentity = Pick<LadderConfig, 'cloud_account_id' | 'provider'>;

export function initLadderAccountSelector(form: HTMLElement, existing?: LadderConfig): () => AccountIdentity | null {
const account = form.querySelector<HTMLSelectElement>('#ladder-cfg-account')!;
const provider = form.querySelector<HTMLSelectElement>('#ladder-cfg-provider')!;
const status = form.querySelector<HTMLElement>('#ladder-cfg-account-status')!;
const save = form.querySelector<HTMLButtonElement>('#ladder-config-save-btn')!;
const identity = existing ? { cloud_account_id: existing.cloud_account_id, provider: existing.provider } : null;
provider.disabled = !!identity;
let accounts: AccountSummary[] | undefined;
let loadFailed = false;
const selection = (): AccountIdentity | null => {
if (identity) return identity;
const selected = accounts?.find(item => item.id === account.value && item.provider === provider.value);
return selected ? { cloud_account_id: selected.id, provider: selected.provider } : null;
};
const render = (): void => {
account.replaceChildren();
if (identity) {
const selected = accounts?.find(item => item.id === identity.cloud_account_id && item.provider === identity.provider);
account.add(new Option(selected ? `${selected.name} (${selected.external_id})` : identity.cloud_account_id, identity.cloud_account_id));
account.disabled = true;
provider.disabled = true;
status.textContent = selected ? 'Account and provider cannot be changed for an existing configuration.'
: !accounts && !loadFailed ? 'Loading registered account details. Saved identity is unchanged.'
: loadFailed ? 'Could not load registered accounts. Saved identity is unchanged.'
: 'Saved account is unavailable in the registered account list. Saved identity is unchanged.';
save.disabled = false;
return;
}
const available = accounts?.filter(item => item.provider === provider.value) ?? [];
account.add(new Option('Select a registered account', ''));
for (const item of available) account.add(new Option(`${item.name} (${item.external_id})`, item.id));
account.disabled = available.length === 0;
save.disabled = true;
status.textContent = loadFailed ? 'Could not load registered accounts. Close and reopen to retry.'
: !accounts ? 'Loading registered accounts...'
: available.length === 0 ? 'No registered accounts are available for this provider.'
: 'Choose a registered account for this provider.';
};
provider.addEventListener('change', render);
account.addEventListener('change', () => { save.disabled = !selection(); });
render();
void listAccountsMinimal().then(loaded => {
accounts = loaded;
render();
}).catch(() => {
loadFailed = true;
render();
});
return selection;
}
31 changes: 13 additions & 18 deletions frontend/src/ladder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { escapeHtml, formatDate } from './utils';
import { showToast } from './toast';
import { canAccess } from './permissions';
import { initLadderTimeline } from './ladder-timeline';
import { initLadderAccountSelector } from './ladder-account-selector';

// ==========================================
// MODULE STATE
Expand All @@ -31,6 +32,7 @@ let configModalSaveInFlight = false;

// Cached configs from the last successful load.
let cachedConfigs: api.LadderConfig[] = [];
let selectedAccount: ReturnType<typeof initLadderAccountSelector> | undefined;

// ==========================================
// PUBLIC API
Expand All @@ -52,6 +54,7 @@ export async function initLadderingSettings(globalEnabled: boolean): Promise<voi
if (!container) return;

container.innerHTML = renderLadderingSection(globalEnabled);
selectedAccount = undefined;
wireKillSwitchToggle();
wireModalCloseButtons();

Expand Down Expand Up @@ -138,9 +141,9 @@ function renderLadderingSection(globalEnabled: boolean): string {
<input type="hidden" id="ladder-cfg-id">

<div class="form-row">
<label for="ladder-cfg-account">Cloud Account ID</label>
<input type="text" id="ladder-cfg-account" placeholder="UUID of the cloud account"
required autocomplete="off">
<label for="ladder-cfg-account">Cloud Account</label>
<select id="ladder-cfg-account" required aria-describedby="ladder-cfg-account-status"></select>
<span id="ladder-cfg-account-status" class="form-hint" role="status"></span>
</div>

<div class="form-row">
Expand Down Expand Up @@ -378,7 +381,6 @@ function openLadderConfigModal(existing?: api.LadderConfig): void {

// Populate modal fields.
setValue('ladder-cfg-id', existing?.id ?? '');
setValue('ladder-cfg-account', existing?.cloud_account_id ?? '');
setSelectValue('ladder-cfg-provider', existing?.provider ?? 'aws');
setChecked('ladder-cfg-enabled', existing?.enabled ?? false);
setSelectValue('ladder-cfg-mode', existing?.mode ?? 'email_approval');
Expand All @@ -397,19 +399,18 @@ function openLadderConfigModal(existing?: api.LadderConfig): void {
setValue('ladder-cfg-ramp-schedule',
existing?.ramp_schedule ? JSON.stringify(existing.ramp_schedule, null, 2) : defaultRamp);

// Disable account/provider fields when editing an existing row (they form
// the upsert key and cannot be changed without a delete+re-create).
const accountInput = document.getElementById('ladder-cfg-account') as HTMLInputElement | null;
const providerSelect = document.getElementById('ladder-cfg-provider') as HTMLSelectElement | null;
if (accountInput) accountInput.readOnly = !!existing;
if (providerSelect) providerSelect.disabled = !!existing;

selectedAccount = initLadderAccountSelector(document.getElementById('ladder-config-form')!, existing);
modal.classList.remove('hidden');
}

// Exported for unit testing (see renderConfigTable note).
export async function saveLadderConfig(): Promise<void> {
if (configModalSaveInFlight) return;
const identity = selectedAccount?.();
if (!canAccess('update', 'config') || !identity) {
showToast({ message: 'Select an available registered account before saving. Configuration update permission is required.', kind: 'error' });
return;
}

const maxHourlyRaw = (document.getElementById('ladder-cfg-max-hourly') as HTMLInputElement | null)?.value?.trim() ?? '';
const maxHourly: number | null = maxHourlyRaw === '' ? null : Number(maxHourlyRaw);
Expand All @@ -429,8 +430,7 @@ export async function saveLadderConfig(): Promise<void> {

const cfg: api.LadderConfig = {
id: (document.getElementById('ladder-cfg-id') as HTMLInputElement | null)?.value || undefined,
cloud_account_id: (document.getElementById('ladder-cfg-account') as HTMLInputElement | null)?.value?.trim() ?? '',
provider: (document.getElementById('ladder-cfg-provider') as HTMLSelectElement | null)?.value ?? 'aws',
...identity,
enabled: (document.getElementById('ladder-cfg-enabled') as HTMLInputElement | null)?.checked ?? false,
mode: ((document.getElementById('ladder-cfg-mode') as HTMLSelectElement | null)?.value ?? 'email_approval') as api.LadderConfig['mode'],
cadence: ((document.getElementById('ladder-cfg-cadence') as HTMLSelectElement | null)?.value ?? 'daily') as api.LadderConfig['cadence'],
Expand All @@ -444,11 +444,6 @@ export async function saveLadderConfig(): Promise<void> {
ramp_schedule: rampSchedule,
};

if (!cfg.cloud_account_id) {
showToast({ message: 'Cloud Account ID is required.', kind: 'error' });
return;
}

configModalSaveInFlight = true;
const saveBtn = document.getElementById('ladder-config-save-btn') as HTMLButtonElement | null;
if (saveBtn) saveBtn.disabled = true;
Expand Down
Loading
Loading