diff --git a/frontend/src/__tests__/ladder.test.ts b/frontend/src/__tests__/ladder.test.ts index ea58bebb..af96ae8c 100644 --- a/frontend/src/__tests__/ladder.test.ts +++ b/frontend/src/__tests__/ladder.test.ts @@ -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(), @@ -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 () => { @@ -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 })); @@ -197,7 +208,9 @@ describe('ladder.ts', () => { // then override max-hourly per case. async function primeValidForm(maxHourly: string): Promise { 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; @@ -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 { + await renderSection(); + document.getElementById('ladder-add-config-btn')!.click(); + } + async function ready(): Promise { 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 = ''; + (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(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(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('.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); + }); + }); }); diff --git a/frontend/src/ladder-account-selector.ts b/frontend/src/ladder-account-selector.ts new file mode 100644 index 00000000..f20a389e --- /dev/null +++ b/frontend/src/ladder-account-selector.ts @@ -0,0 +1,55 @@ +import { listAccountsMinimal, type AccountSummary } from './api/accounts'; +import type { LadderConfig } from './api/ladder'; + +type AccountIdentity = Pick; + +export function initLadderAccountSelector(form: HTMLElement, existing?: LadderConfig): () => AccountIdentity | null { + const account = form.querySelector('#ladder-cfg-account')!; + const provider = form.querySelector('#ladder-cfg-provider')!; + const status = form.querySelector('#ladder-cfg-account-status')!; + const save = form.querySelector('#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; +} diff --git a/frontend/src/ladder.ts b/frontend/src/ladder.ts index b9d6408a..96fdac22 100644 --- a/frontend/src/ladder.ts +++ b/frontend/src/ladder.ts @@ -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 @@ -31,6 +32,7 @@ let configModalSaveInFlight = false; // Cached configs from the last successful load. let cachedConfigs: api.LadderConfig[] = []; +let selectedAccount: ReturnType | undefined; // ========================================== // PUBLIC API @@ -52,6 +54,7 @@ export async function initLadderingSettings(globalEnabled: boolean): Promise
- - + + +
@@ -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'); @@ -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 { 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); @@ -429,8 +430,7 @@ export async function saveLadderConfig(): Promise { 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'], @@ -444,11 +444,6 @@ export async function saveLadderConfig(): Promise { 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; diff --git a/frontend/tests-e2e/ladder-account-selector.spec.ts b/frontend/tests-e2e/ladder-account-selector.spec.ts new file mode 100644 index 00000000..ea1d1cfe --- /dev/null +++ b/frontend/tests-e2e/ladder-account-selector.spec.ts @@ -0,0 +1,133 @@ +import { test, expect, type Page, type Route } from '@playwright/test'; +import { mockApi, seedAuth } from './fixtures/recs'; +import type { LadderConfig } from '../src/api/ladder'; + +const accounts = [ + { id: 'registered-aws', name: '', external_id: '', provider: 'aws' }, + { id: 'registered-azure', name: 'Azure fixture', external_id: 'subscription-fixture', provider: 'azure' }, + { id: 'registered-gcp', name: 'GCP fixture', external_id: 'project-fixture', provider: 'gcp' }, +]; +const existing: LadderConfig = { + id: 'saved-config', cloud_account_id: 'unavailable-saved-account', provider: 'azure', enabled: false, + mode: 'email_approval', cadence: 'daily', target_coverage: 80, 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 }] }, +}; + +async function setup(page: Page): Promise<{ saves: LadderConfig[]; writes: string[]; errors: string[]; failures: string[] }> { + const saves: LadderConfig[] = []; + const writes: string[] = []; + const errors: string[] = []; + const failures: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + page.on('console', message => { + if (message.type() === 'error' && !(message.text().includes('403') && message.location().url.endsWith('/api/accounts/list'))) errors.push(message.text()); + }); + page.on('response', response => { if (response.status() >= 400) failures.push(`${response.status()} ${new URL(response.url()).pathname}`); }); + page.on('requestfailed', request => failures.push(`${request.failure()?.errorText} ${new URL(request.url()).pathname}`)); + page.on('request', request => { if (request.method() !== 'GET') writes.push(new URL(request.url()).pathname); }); + await page.route('**/*', route => new URL(route.request().url()).hostname === '127.0.0.1' ? route.fallback() : route.abort()); + await seedAuth(page); + await mockApi(page); + await page.route('**/api/config', route => route.fulfill({ json: { global: {}, services: [] } })); + await page.route('**/api/commitment-options', route => route.fulfill({ json: { status: 'ok', aws: {} } })); + await page.route('**/api/ri-exchange/config', route => route.fulfill({ json: { + mode: 'manual', auto_exchange_enabled: false, utilization_threshold: 80, + max_payment_per_exchange_usd: 0, max_payment_daily_usd: 0, lookback_days: 30, + } })); + await page.route('**/api/accounts/list', route => route.fulfill({ json: accounts })); + await page.route('**/api/ladder/configs', async route => { + if (route.request().method() === 'GET') return route.fulfill({ json: { configs: [existing] } }); + expect(route.request().method()).toBe('PUT'); + const saved = route.request().postDataJSON() as LadderConfig; + saves.push(saved); + await route.fulfill({ json: saved }); + }); + await page.route('**/api/ladder/runs?*', route => route.fulfill({ json: { total_count: 0, runs: [] } })); + await page.route('**/api/ladder/tranches?*', route => route.fulfill({ json: { total_count: 0, total_usd_hr: '0.000000', events: [] } })); + await page.goto('/admin/purchasing'); + await expect(page.locator('#ladder-add-config-btn')).toBeVisible(); + await expect(page.locator('#global-settings-form')).not.toHaveClass(/hidden/); + return { saves, writes, errors, failures }; +} + +for (const width of [1280, 390]) { + test(`registered account selection filters providers and saves the internal ID at ${width}px`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + const { saves, writes, errors, failures } = await setup(page); + const modal = page.getByRole('dialog', { name: 'Commitment Laddering Config' }); + const account = modal.getByLabel('Cloud Account', { exact: true }); + const provider = modal.getByLabel('Provider', { exact: true }); + await page.locator('#ladder-add-config-btn').click(); + await expect(account).toBeEnabled(); + await expect(account).toHaveValue(''); + await expect(page.locator('#ladder-config-save-btn')).toBeDisabled(); + await expect(account.locator('option')).toHaveText(['Select a registered account', ' ()']); + await expect(page.locator('#ladder-config-modal img')).toHaveCount(0); + await account.selectOption('registered-aws'); + await provider.selectOption('gcp'); + await expect(account).toHaveValue(''); + await expect(account.locator('option')).toHaveText(['Select a registered account', 'GCP fixture (project-fixture)']); + await expect(page.locator('#ladder-config-save-btn')).toBeDisabled(); + await account.selectOption('registered-gcp'); + await page.locator('#ladder-config-save-btn').click(); + await expect(page.locator('#ladder-config-modal')).toBeHidden(); + expect(saves).toHaveLength(1); + expect(saves[0]).toMatchObject({ cloud_account_id: 'registered-gcp', provider: 'gcp' }); + expect(writes).toEqual(['/api/ladder/configs']); + expect(errors).toEqual([]); + expect(failures).toEqual([]); + }); +} + +test('a forbidden list blocks creation but preserves an unavailable edit identity', async ({ page }) => { + const { saves, writes, errors, failures } = await setup(page); + await page.route('**/api/accounts/list', route => route.fulfill({ status: 403, json: { error: 'Forbidden' } })); + await page.locator('#ladder-add-config-btn').click(); + await expect(page.locator('#ladder-cfg-account-status')).toContainText('Could not load registered accounts'); + await expect(page.locator('#ladder-cfg-account')).toBeDisabled(); + await expect(page.locator('#ladder-config-save-btn')).toBeDisabled(); + await page.locator('#ladder-modal-cancel-btn').click(); + await page.locator('.ladder-edit-btn').click(); + await expect(page.locator('#ladder-cfg-account-status')).toContainText('Saved identity is unchanged'); + await expect(page.locator('#ladder-cfg-account')).toHaveValue(existing.cloud_account_id); + await expect(page.locator('#ladder-cfg-account')).toBeDisabled(); + await expect(page.locator('#ladder-cfg-provider')).toBeDisabled(); + await page.locator('#ladder-cfg-target-coverage').fill('75'); + await page.locator('#ladder-config-save-btn').click(); + await expect(page.locator('#ladder-config-modal')).toBeHidden(); + expect(saves).toEqual([{ ...existing, target_coverage: 75 }]); + expect(writes).toEqual(['/api/ladder/configs']); + expect(errors).toEqual([]); + expect(failures).toEqual(['403 /api/accounts/list', '403 /api/accounts/list']); +}); + +test('provider changes during loading and stale reopened responses keep the current account list', async ({ page }) => { + const { saves, writes, errors, failures } = await setup(page); + let pending!: Route; + await page.route('**/api/accounts/list', route => { pending = route; }); + await page.locator('#ladder-add-config-btn').click(); + await expect(page.locator('#ladder-cfg-account-status')).toContainText('Loading registered accounts'); + await expect.poll(() => pending !== undefined).toBe(true); + await page.locator('#ladder-cfg-provider').selectOption('gcp'); + await pending.fulfill({ json: accounts }); + await expect(page.locator('#ladder-cfg-account option')).toHaveText(['Select a registered account', 'GCP fixture (project-fixture)']); + const completed = pending; + await page.locator('#ladder-modal-cancel-btn').click(); + await page.locator('#ladder-add-config-btn').click(); + await expect(page.locator('#ladder-cfg-account-status')).toContainText('Loading registered accounts'); + await expect.poll(() => pending !== completed).toBe(true); + const stale = pending; + await page.locator('#ladder-modal-cancel-btn').click(); + await page.route('**/api/accounts/list', route => route.fulfill({ json: accounts })); + await page.locator('#ladder-add-config-btn').click(); + await expect(page.locator('#ladder-cfg-account')).toBeEnabled(); + await page.locator('#ladder-cfg-account').selectOption('registered-aws'); + await stale.fulfill({ json: [] }); + await expect(page.locator('#ladder-cfg-account')).toHaveValue('registered-aws'); + expect(saves).toEqual([]); + expect(writes).toEqual([]); + expect(errors).toEqual([]); + expect(failures).toEqual([]); +}); diff --git a/frontend/tests-e2e/ladder-config.spec.ts b/frontend/tests-e2e/ladder-config.spec.ts index b35da9b9..e3dd0ce5 100644 --- a/frontend/tests-e2e/ladder-config.spec.ts +++ b/frontend/tests-e2e/ladder-config.spec.ts @@ -14,6 +14,7 @@ for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 ? route.fallback() : route.abort()); await seedAuth(page); await mockApi(page); + await page.route('**/api/config', route => route.fulfill({ json: { global: {}, services: [] } })); await page.route('**/api/commitment-options', route => route.fulfill({ json: { status: 'ok', aws: {} } })); await page.route('**/api/ri-exchange/config', route => route.fulfill({ json: { mode: 'manual', auto_exchange_enabled: false, utilization_threshold: 80, @@ -58,7 +59,7 @@ for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 await expect(field('provider')).toHaveValue(config.provider); await expect(field('provider')).toBeDisabled(); await expect(field('account')).toHaveValue(config.cloud_account_id); - await expect(field('account')).toHaveAttribute('readonly'); + await expect(field('account')).toBeDisabled(); await expect(field('id')).toHaveValue(config.id!); await expect(field('mode')).toHaveValue(config.mode); await expect(field('cadence')).toHaveValue(config.cadence); @@ -83,12 +84,13 @@ for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 expect(saves[index]).toEqual(expected); await expect(page.locator('#ladder-configs-table-container tbody tr').filter({ has: edit('azure') })).toContainText(`${target.toFixed(1)}%`); await page.getByRole('button', { name: 'Dismiss notification' }).click(); + await expect(page.getByRole('button', { name: 'Dismiss notification' })).toHaveCount(0); } await page.locator('#ladder-add-config-btn').click(); await expect(field('id')).toHaveValue(''); await expect(field('account')).toHaveValue(''); - await expect(field('account')).not.toHaveAttribute('readonly'); + await expect(field('account')).toBeEnabled(); await expect(field('provider')).toBeEnabled(); await expect(field('provider')).toHaveValue('aws'); await expect(field('mode')).toHaveValue('email_approval'); @@ -99,15 +101,15 @@ for (const viewport of [{ width: 1280, height: 900 }, { width: 390, height: 844 'lookback-days': '30', 'buf-util-threshold': '90', 'max-hourly': '', 'max-actions': '10', })) await expect(field(name)).toHaveValue(value); expect(JSON.parse(await field('ramp-schedule').inputValue())).toEqual({ steps: [{ after_days: 0, fraction: 1 }] }); - await field('account').fill('new-account'); await field('provider').selectOption('gcp'); + await field('account').selectOption('acct-200'); await field('mode').selectOption('auto_approve'); await field('cadence').selectOption('weekly'); await page.locator('#ladder-config-save-btn').click(); await expect(modal).toBeHidden(); expect(saves).toHaveLength(3); expect(saves[2]).toEqual({ - cloud_account_id: 'new-account', provider: 'gcp', mode: 'auto_approve', cadence: 'weekly', + cloud_account_id: 'acct-200', provider: 'gcp', mode: 'auto_approve', cadence: 'weekly', enabled: false, 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 }] },