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
106 changes: 45 additions & 61 deletions frontend/src/__tests__/commitmentOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,33 +53,17 @@ describe('commitmentOptions', () => {
expect(config.invalidCombinations![0]).toEqual({ term: 3, payment: 'no-upfront' });
});

it('should return ElastiCache config with invalid 3yr no-upfront combination', () => {
const config = getCommitmentConfig('aws', 'elasticache');

expect(config.invalidCombinations).toBeDefined();
expect(config.invalidCombinations![0]).toEqual({ term: 3, payment: 'no-upfront' });
});

it('should return OpenSearch config with invalid 3yr no-upfront combination', () => {
const config = getCommitmentConfig('aws', 'opensearch');

expect(config.invalidCombinations).toBeDefined();
expect(config.invalidCombinations![0]).toEqual({ term: 3, payment: 'no-upfront' });
});

it('should return Redshift config with invalid 3yr no-upfront combination', () => {
const config = getCommitmentConfig('aws', 'redshift');

expect(config.invalidCombinations).toBeDefined();
expect(config.invalidCombinations![0]).toEqual({ term: 3, payment: 'no-upfront' });
});

it('should return MemoryDB config with invalid 3yr no-upfront combination', () => {
const config = getCommitmentConfig('aws', 'memorydb');

expect(config.invalidCombinations).toBeDefined();
expect(config.invalidCombinations![0]).toEqual({ term: 3, payment: 'no-upfront' });
});
it.each(['elasticache', 'opensearch', 'redshift', 'memorydb'])(
'should return %s config with no invalidCombinations (AWS restricts only RDS 3yr no-upfront)',
(service) => {
const config = getCommitmentConfig('aws', service);
// These services were previously listed as also rejecting 3yr
// no-upfront, but that was over-cautious — AWS does offer it.
// The backend agrees: cmd/validators.go:warnRDS3YearNoUpfront
// warns only on RDS. They fall through to the AWS _default.
expect(config.invalidCombinations).toBeUndefined();
},
);

it('should return default AWS config for unknown service', () => {
const config = getCommitmentConfig('aws', 'unknown-service');
Expand Down Expand Up @@ -205,23 +189,31 @@ describe('commitmentOptions', () => {
});

describe('AWS services with 3yr no-upfront restriction', () => {
const restrictedServices = ['rds', 'elasticache', 'opensearch', 'redshift', 'memorydb'];

it.each(restrictedServices)('should return false for %s 3yr no-upfront', (service) => {
expect(isValidCombination('aws', service, 3, 'no-upfront')).toBe(false);
// Only RDS has this restriction. ElastiCache / OpenSearch /
// Redshift / MemoryDB were previously listed too but AWS does
// offer 3yr no-upfront for those.
it('should return false for rds 3yr no-upfront', () => {
expect(isValidCombination('aws', 'rds', 3, 'no-upfront')).toBe(false);
});

it.each(restrictedServices)('should return true for %s 1yr no-upfront', (service) => {
expect(isValidCombination('aws', service, 1, 'no-upfront')).toBe(true);
it('should return true for rds 1yr no-upfront', () => {
expect(isValidCombination('aws', 'rds', 1, 'no-upfront')).toBe(true);
});

it.each(restrictedServices)('should return true for %s 3yr partial-upfront', (service) => {
expect(isValidCombination('aws', service, 3, 'partial-upfront')).toBe(true);
it('should return true for rds 3yr partial-upfront', () => {
expect(isValidCombination('aws', 'rds', 3, 'partial-upfront')).toBe(true);
});

it.each(restrictedServices)('should return true for %s 3yr all-upfront', (service) => {
expect(isValidCombination('aws', service, 3, 'all-upfront')).toBe(true);
it('should return true for rds 3yr all-upfront', () => {
expect(isValidCombination('aws', 'rds', 3, 'all-upfront')).toBe(true);
});

it.each(['elasticache', 'opensearch', 'redshift', 'memorydb'])(
'should return true for %s 3yr no-upfront (not restricted)',
(service) => {
expect(isValidCombination('aws', service, 3, 'no-upfront')).toBe(true);
},
);
});

describe('Azure and GCP', () => {
Expand Down Expand Up @@ -288,19 +280,15 @@ describe('commitmentOptions', () => {
expect(options.map(o => o.value)).toContain('no-upfront');
});

it('should exclude no-upfront for ElastiCache 3-year term', () => {
const options = getValidPaymentOptions('aws', 'elasticache', 3);

expect(options).toHaveLength(2);
expect(options.map(o => o.value)).not.toContain('no-upfront');
});

it('should exclude no-upfront for OpenSearch 3-year term', () => {
const options = getValidPaymentOptions('aws', 'opensearch', 3);
it.each(['elasticache', 'opensearch', 'redshift', 'memorydb'])(
'should keep no-upfront for %s 3-year term (AWS offers it)',
(service) => {
const options = getValidPaymentOptions('aws', service, 3);

expect(options).toHaveLength(2);
expect(options.map(o => o.value)).not.toContain('no-upfront');
});
expect(options).toHaveLength(3);
expect(options.map(o => o.value)).toContain('no-upfront');
},
);
});

describe('Azure', () => {
Expand Down Expand Up @@ -367,19 +355,15 @@ describe('commitmentOptions', () => {
expect(options).toHaveLength(2);
});

it('should exclude 3-year for ElastiCache with no-upfront', () => {
const options = getValidTermOptions('aws', 'elasticache', 'no-upfront');

expect(options).toHaveLength(1);
expect(options[0]!.value).toBe(1);
});

it('should exclude 3-year for OpenSearch with no-upfront', () => {
const options = getValidTermOptions('aws', 'opensearch', 'no-upfront');
it.each(['elasticache', 'opensearch', 'redshift', 'memorydb'])(
'should keep 3-year for %s with no-upfront (AWS offers it)',
(service) => {
const options = getValidTermOptions('aws', service, 'no-upfront');

expect(options).toHaveLength(1);
expect(options[0]!.value).toBe(1);
});
expect(options).toHaveLength(2);
expect(options.map(o => o.value)).toEqual([1, 3]);
},
);
});

describe('Azure', () => {
Expand Down
161 changes: 153 additions & 8 deletions frontend/src/__tests__/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ describe('Settings Module', () => {
<select id="aws-opensearch-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-redshift-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-savingsplans-term"><option value="1">1</option><option value="3">3</option></select>
<select id="aws-ec2-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-rds-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-elasticache-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-opensearch-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-redshift-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-savingsplans-payment"><option value="all-upfront">All</option><option value="no-upfront">No</option></select>
<select id="aws-ec2-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-rds-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-elasticache-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-opensearch-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-redshift-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<select id="aws-savingsplans-payment"><option value="no-upfront">No</option><option value="partial-upfront">Partial</option><option value="all-upfront">All</option></select>
<!-- Azure term selects -->
<select id="azure-vm-term"><option value="1">1</option><option value="3">3</option></select>
<select id="azure-sql-term"><option value="1">1</option><option value="3">3</option></select>
Expand Down Expand Up @@ -198,7 +198,7 @@ describe('Settings Module', () => {
expect((document.getElementById('gcp-compute-term') as HTMLSelectElement).value).toBe('1');
});

test('sets up default payment to propagate to AWS services (after confirm)', async () => {
test('sets up default payment to propagate to AWS services (after confirm), clamping where per-service constraints reject the value', async () => {
setupSettingsHandlers();
mockConfirmDialog.mockResolvedValueOnce(true);

Expand All @@ -208,8 +208,15 @@ describe('Settings Module', () => {
await new Promise((r) => setTimeout(r, 0));

expect(mockConfirmDialog).toHaveBeenCalledTimes(1);
// EC2 accepts no-upfront at both terms — propagation lands as-is.
expect((document.getElementById('aws-ec2-payment') as HTMLSelectElement).value).toBe('no-upfront');
expect((document.getElementById('aws-rds-payment') as HTMLSelectElement).value).toBe('no-upfront');
// RDS 3yr rejects no-upfront (parent beforeEach seeds all service
// terms at "3"), so the constraint sync clamps RDS back to the
// first valid payment option instead of persisting an invalid
// combination the provider will refuse.
const rdsPayment = (document.getElementById('aws-rds-payment') as HTMLSelectElement).value;
expect(rdsPayment).not.toBe('no-upfront');
expect(['partial-upfront', 'all-upfront']).toContain(rdsPayment);
});

test('cancelling the cascade restores the default term to its prior value', async () => {
Expand Down Expand Up @@ -719,4 +726,142 @@ describe('Settings Module', () => {
expect(html).not.toMatch(/Azure and GCP reservations are always paid upfront/);
});
});

// Guard against the RDS 3yr + no-upfront regression from follow-up to
// issue #12. The backend rejects that combination (and EC/OpenSearch/
// Redshift 3yr no-upfront), so the Settings form must not allow it.
// Rules live in commitmentOptions.ts; these tests exercise the wiring
// that applies them to the per-service dropdowns.
describe('per-service term/payment combination constraints', () => {
const optVisible = (sel: HTMLSelectElement, value: string): boolean => {
const opt = Array.from(sel.options).find(o => o.value === value);
if (!opt) return false;
return !opt.hidden && !opt.disabled;
};

test('RDS 3yr hides "no-upfront" and keeps partial/all upfront selectable', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 },
services: [{ provider: 'aws', service: 'rds', term: 3, payment: 'all-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const rdsPayment = document.getElementById('aws-rds-payment') as HTMLSelectElement;
expect(optVisible(rdsPayment, 'no-upfront')).toBe(false);
expect(optVisible(rdsPayment, 'partial-upfront')).toBe(true);
expect(optVisible(rdsPayment, 'all-upfront')).toBe(true);
});

test('RDS 1yr keeps all three payment options visible', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 1, default_payment: 'no-upfront', default_coverage: 80 },
services: [{ provider: 'aws', service: 'rds', term: 1, payment: 'no-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const rdsPayment = document.getElementById('aws-rds-payment') as HTMLSelectElement;
expect(optVisible(rdsPayment, 'no-upfront')).toBe(true);
expect(optVisible(rdsPayment, 'partial-upfront')).toBe(true);
expect(optVisible(rdsPayment, 'all-upfront')).toBe(true);
});

test('switching RDS term 1yr → 3yr while "no-upfront" is selected auto-clamps payment', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 1, default_payment: 'no-upfront', default_coverage: 80 },
services: [{ provider: 'aws', service: 'rds', term: 1, payment: 'no-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const rdsTerm = document.getElementById('aws-rds-term') as HTMLSelectElement;
const rdsPayment = document.getElementById('aws-rds-payment') as HTMLSelectElement;
expect(rdsPayment.value).toBe('no-upfront');

rdsTerm.value = '3';
rdsTerm.dispatchEvent(new Event('change'));

// no-upfront is now invalid; payment should snap to first valid option
expect(rdsPayment.value).not.toBe('no-upfront');
expect(['partial-upfront', 'all-upfront']).toContain(rdsPayment.value);
expect(optVisible(rdsPayment, 'no-upfront')).toBe(false);
});

test('legacy-persisted invalid combo (RDS 3yr + no-upfront) is clamped on load', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 },
// Simulate a config stored before this guardrail existed.
services: [{ provider: 'aws', service: 'rds', term: 3, payment: 'no-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const rdsPayment = document.getElementById('aws-rds-payment') as HTMLSelectElement;
expect(rdsPayment.value).not.toBe('no-upfront');
});

test('EC2 3yr keeps all three payment options visible (no service-level restriction)', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 },
services: [{ provider: 'aws', service: 'ec2', term: 3, payment: 'no-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const ec2Payment = document.getElementById('aws-ec2-payment') as HTMLSelectElement;
expect(optVisible(ec2Payment, 'no-upfront')).toBe(true);
expect(optVisible(ec2Payment, 'partial-upfront')).toBe(true);
expect(optVisible(ec2Payment, 'all-upfront')).toBe(true);
expect(ec2Payment.value).toBe('no-upfront');
});

test.each(['elasticache', 'opensearch', 'redshift'])(
'%s 3yr keeps "no-upfront" visible (AWS only restricts RDS)',
async (service) => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'no-upfront', default_coverage: 80 },
services: [{ provider: 'aws', service, term: 3, payment: 'no-upfront' }],
});
setupSettingsHandlers();
await loadGlobalSettings();

const payment = document.getElementById(`aws-${service}-payment`) as HTMLSelectElement;
expect(optVisible(payment, 'no-upfront')).toBe(true);
// And the selected value round-trips cleanly — the backend persists
// this service with no-upfront, and the UI should not clamp it.
expect(payment.value).toBe('no-upfront');
},
);

test('propagating global "no-upfront" to all services while term=3 clamps restricted services', async () => {
(api.getConfig as jest.Mock).mockResolvedValue({
global: { enabled_providers: ['aws'], default_term: 3, default_payment: 'all-upfront', default_coverage: 80 },
services: [
{ provider: 'aws', service: 'ec2', term: 3, payment: 'all-upfront' },
{ provider: 'aws', service: 'rds', term: 3, payment: 'all-upfront' },
],
});
setupSettingsHandlers();
await loadGlobalSettings();

// User changes the global default to no-upfront and confirms the propagation.
mockConfirmDialog.mockResolvedValue(true);
const defaultPayment = document.getElementById('setting-default-payment') as HTMLSelectElement;
defaultPayment.dataset['previous'] = 'all-upfront';
defaultPayment.value = 'no-upfront';
defaultPayment.dispatchEvent(new Event('change'));

// Allow the async confirmDialog promise to resolve.
await Promise.resolve();
await Promise.resolve();

const ec2Payment = document.getElementById('aws-ec2-payment') as HTMLSelectElement;
const rdsPayment = document.getElementById('aws-rds-payment') as HTMLSelectElement;
// EC2 accepts the propagated no-upfront (no restriction).
expect(ec2Payment.value).toBe('no-upfront');
// RDS 3yr rejects no-upfront, so it clamps back to the first valid option.
expect(rdsPayment.value).not.toBe('no-upfront');
});
});
});
40 changes: 7 additions & 33 deletions frontend/src/commitmentOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,46 +59,20 @@ const commitmentConfigs: Record<string, Record<string, CommitmentConfig>> = {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS
},
// RDS - no 3-year no-upfront option
// RDS - no 3-year no-upfront option. This is the only AWS service
// with a hard restriction; the backend validator in
// cmd/validators.go:warnRDS3YearNoUpfront agrees, and the newer
// lib/purchase-compatibility.ts calls it out as "the one hard rule".
// ElastiCache / OpenSearch / Redshift / MemoryDB were previously
// listed here too, but that was over-cautious copy-paste — AWS does
// offer 3yr no-upfront on those services.
rds: {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: [
{ term: 3, payment: 'no-upfront' }
]
},
// ElastiCache - no 3-year no-upfront option
elasticache: {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: [
{ term: 3, payment: 'no-upfront' }
]
},
// OpenSearch - no 3-year no-upfront option
opensearch: {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: [
{ term: 3, payment: 'no-upfront' }
]
},
// Redshift - no 3-year no-upfront option
redshift: {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: [
{ term: 3, payment: 'no-upfront' }
]
},
// MemoryDB - no 3-year no-upfront option
memorydb: {
terms: STANDARD_TERMS,
payments: AWS_PAYMENTS,
invalidCombinations: [
{ term: 3, payment: 'no-upfront' }
]
},
// Default for AWS services not specifically configured
_default: {
terms: STANDARD_TERMS,
Expand Down
Loading