diff --git a/frontend/src/__tests__/api-insurance.test.ts b/frontend/src/__tests__/api-insurance.test.ts index bf0b27ca..cf11625e 100644 --- a/frontend/src/__tests__/api-insurance.test.ts +++ b/frontend/src/__tests__/api-insurance.test.ts @@ -1,4 +1,4 @@ -import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import { getInsuranceComparison, getInsuranceStatus, getRecommendationInsuranceComparison } from '../api/insurance'; import { apiRequest } from '../api/client'; jest.mock('../api/client', () => ({ apiRequest: jest.fn() })); @@ -16,3 +16,8 @@ describe('insurance api', () => { expect(apiRequest).toHaveBeenCalledWith('/insurance/comparison'); }); }); + +test('recommendation comparison encodes the exact stored ID', async () => { + await getRecommendationInsuranceComparison('stored/id?&'); + expect(apiRequest).toHaveBeenLastCalledWith('/insurance/comparison?recommendation_id=stored%2Fid%3F%26'); +}); diff --git a/frontend/src/__tests__/recommendation-archera-comparison.test.ts b/frontend/src/__tests__/recommendation-archera-comparison.test.ts new file mode 100644 index 00000000..4d69b745 --- /dev/null +++ b/frontend/src/__tests__/recommendation-archera-comparison.test.ts @@ -0,0 +1,153 @@ +import { mountRecommendationArchera } from '../recommendation-archera-comparison'; +import { getInsuranceStatus, getRecommendationInsuranceComparison } from '../api/insurance'; +import type { RecommendationArcheraComparison, RecommendationArcheraOffer } from '../api/types'; +import { canAccess } from '../permissions'; + +jest.mock('../api/insurance', () => ({ + getInsuranceStatus: jest.fn(), getRecommendationInsuranceComparison: jest.fn(), +})); +jest.mock('../permissions', () => ({ canAccess: jest.fn() })); + +const figures: RecommendationArcheraOffer = { + offer_id: '', provider: 'aws', commitment_type: 'savings_plan', + hourly_selected_commitment: '0.500000000000000001', native_duration_seconds: '31536000', + native_plan_type: 'EC2Instance', native_instance_family: 'm7i', effective_contract_term: 'one_year_gris', + payment_option: 'no_upfront', lease_attached: false, upfront_one_time_cost: '1200', + monthly: { + monthly_730h_commitment_cost_total: '110', monthly_730h_cloud_provider_cost: '100', + monthly_730h_archera_premium: '10', monthly_730h_gross_savings: null, + monthly_730h_net_savings: '20', monthly_730h_covered_on_demand_cost: '130', + }, +}; +const result: RecommendationArcheraComparison = { + recommendation_id: 'stored/id', status: 'resource_associated', reason: '', + amount_provenance: 'stored_decimal_only', economic_status: 'unavailable_source_precision', + fetched_at: '2026-10-15T12:00:00Z', currency: null, comparable_delta: null, + currency_note: 'Vendor currency unavailable', premium_included: true, + current: figures, candidates: [ + { offer_id: 'same', status: 'vendor_alternative', reason: '', native_identity_status: 'unavailable', + figures: { ...figures, native_duration_seconds: null, native_plan_type: null, native_instance_family: null } }, + { offer_id: 'same', status: 'unavailable', reason: 'amount_unproven', native_identity_status: 'unavailable', figures: null }, + ], + non_gating_disclosure: 'Purchases do not depend on Archera.', + sponsorship_disclosure: 'Sponsored by Archera.', +}; +const flush = async (): Promise => { await Promise.resolve(); await Promise.resolve(); }; +const action = (): HTMLButtonElement => document.querySelector('[data-action="archera"]')!; + +beforeEach(() => { + document.body.innerHTML = '
'; + jest.clearAllMocks(); + (canAccess as jest.Mock).mockReturnValue(true); + (getInsuranceStatus as jest.Mock).mockResolvedValue({ configured: true, missing: [] }); + (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue(result); +}); + +test('explicit row action uses the stored ID without comparison on mount or repeated mount', async () => { + await mountRecommendationArchera(document.body); + await mountRecommendationArchera(document.body); + expect(document.querySelectorAll('[data-action="archera"]')).toHaveLength(1); + expect(getRecommendationInsuranceComparison).not.toHaveBeenCalled(); + action().click(); + await flush(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledWith('stored/id'); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); +}); + +test.each(['permission', 'unconfigured', 'status_error'])('%s does not expose the action or fetch comparison', async mode => { + if (mode === 'permission') (canAccess as jest.Mock).mockReturnValue(false); + if (mode === 'unconfigured') (getInsuranceStatus as jest.Mock).mockResolvedValue({ configured: false }); + if (mode === 'status_error') (getInsuranceStatus as jest.Mock).mockRejectedValue(new Error('offline')); + await mountRecommendationArchera(document.body); + expect(document.querySelector('button')).toBeNull(); + expect(getRecommendationInsuranceComparison).not.toHaveBeenCalled(); + if (mode === 'permission') expect(getInsuranceStatus).not.toHaveBeenCalled(); +}); + +test('loading deduplicates across rerenders and closing never reopens the response', async () => { + let resolve!: (value: RecommendationArcheraComparison) => void; + (getRecommendationInsuranceComparison as jest.Mock).mockReturnValue(new Promise(r => { resolve = r; })); + await mountRecommendationArchera(document.body); + action().click(); + expect(action().disabled).toBe(true); + expect(document.body.textContent).toContain('Loading Archera alternatives'); + const row = document.querySelector('tr')!; + row.querySelector('button')!.remove(); + await mountRecommendationArchera(document.body); + action().click(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledTimes(1); + (document.querySelector('[role="dialog"] button') as HTMLButtonElement).click(); + resolve(result); + await flush(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); +}); + +test('native exact figures, null economics, lease distinctions and disclosures remain text', async () => { + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.querySelector('img')).toBeNull(); + for (const text of [figures.offer_id, '0.500000000000000001', '31536000', 'one_year_gris', + '1200', '110', 'Unknown', 'Original provider precision is unproven', 'Currency: unknown', + 'Comparable cost difference: unavailable', result.non_gating_disclosure, result.sponsorship_disclosure]) { + expect(dialog.textContent).toContain(text); + } + expect(dialog.textContent).not.toMatch(/\$|USD/); + expect(dialog.querySelectorAll('h3')).toHaveLength(2); + expect(dialog.textContent).toContain('Alternative 2: unavailable. amount unproven'); + expect(dialog.textContent).toContain('native product identity is unavailable'); +}); + +test.each([[400, 'invalid'], [403, 'not authorised'], [404, 'no longer exists'], [409, 'changed or is ambiguous'], + [422, 'cannot be compared'], [502, 'unavailable or returned'], [503, 'not available'], [504, 'did not respond']])( +'status %i shows a fixed message and never the response text', async (status, text) => { + (getRecommendationInsuranceComparison as jest.Mock).mockRejectedValue(Object.assign(new Error(''), { status })); + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.textContent).toContain(text); + expect(dialog.textContent).not.toContain(' { + if (mode === 'unmatched') (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue({ + ...result, status: 'unavailable', reason: 'identity_unproven', current: null, candidates: [], + }); + if (mode === 'error') (getRecommendationInsuranceComparison as jest.Mock).mockRejectedValue(new Error('')); + if (mode === 'wrong_id') (getRecommendationInsuranceComparison as jest.Mock).mockResolvedValue({ ...result, recommendation_id: 'another' }); + await mountRecommendationArchera(document.body); + action().click(); + await flush(); + const dialog = document.querySelector('[role="dialog"]')!; + expect(dialog.querySelectorAll('h3')).toHaveLength(0); + expect(dialog.querySelector('img')).toBeNull(); + expect(action().disabled).toBe(false); + if (mode === 'unmatched') expect(dialog.textContent).toContain('identity unproven'); + if (mode === 'error') expect(dialog.textContent).toContain('Archera alternatives unavailable.'); + if (mode === 'wrong_id') expect(dialog.textContent).toContain('changed or is ambiguous'); +}); + +test('closing while loading restores focus without allowing a duplicate request', async () => { + let resolve!: (value: RecommendationArcheraComparison) => void; + (getRecommendationInsuranceComparison as jest.Mock).mockReturnValue(new Promise(r => { resolve = r; })); + await mountRecommendationArchera(document.body); + const button = action(); + button.focus(); + button.click(); + (document.querySelector('[role="dialog"] button') as HTMLButtonElement).click(); + expect(document.activeElement).toBe(button); + button.click(); + expect(getRecommendationInsuranceComparison).toHaveBeenCalledTimes(1); + resolve(result); + await flush(); +}); + +test('the module imports no purchase or plan API', () => { + const source = jest.requireActual('fs').readFileSync(require.resolve('../recommendation-archera-comparison'), 'utf8'); + const imports = [...source.matchAll(/from '([^']+)'/g)].map(m => m[1]); + expect(imports).toEqual(['./api/insurance', './api/types', './permissions', './modal']); +}); diff --git a/frontend/src/api/insurance.ts b/frontend/src/api/insurance.ts index a54cd71a..cd88424d 100644 --- a/frontend/src/api/insurance.ts +++ b/frontend/src/api/insurance.ts @@ -3,7 +3,7 @@ */ import { apiRequest } from './client'; -import type { ArcheraComparison, InsuranceStatus } from './types'; +import type { ArcheraComparison, InsuranceStatus, RecommendationArcheraComparison } from './types'; /** Reports whether the comparison is configured. Makes no vendor call. */ export async function getInsuranceStatus(): Promise { @@ -14,3 +14,7 @@ export async function getInsuranceStatus(): Promise { export async function getInsuranceComparison(): Promise { return apiRequest('/insurance/comparison'); } + +export async function getRecommendationInsuranceComparison(id: string): Promise { + return apiRequest(`/insurance/comparison?recommendation_id=${encodeURIComponent(id)}`); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index f3ad8bc6..9a0a8903 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -1056,3 +1056,41 @@ export interface ArcheraComparison { non_gating_disclosure: string; sponsorship_disclosure: string; } + +export interface RecommendationArcheraOffer { + offer_id: string; + provider: string; + commitment_type: string; + hourly_selected_commitment: string | null; + native_duration_seconds: string | null; + native_plan_type: string | null; + native_instance_family: string | null; + effective_contract_term: string | null; + payment_option: string | null; + lease_attached: boolean; + monthly: ArcheraFinancials; + upfront_one_time_cost: string | null; +} + +export interface RecommendationArcheraComparison { + recommendation_id: string; + status: string; + reason: string; + amount_provenance: string; + economic_status: string; + fetched_at: string; + currency: string | null; + currency_note: string; + comparable_delta: string | null; + premium_included: boolean; + current: RecommendationArcheraOffer | null; + candidates: Array<{ + offer_id: string; + status: string; + reason: string; + native_identity_status: string; + figures: RecommendationArcheraOffer | null; + }>; + non_gating_disclosure: string; + sponsorship_disclosure: string; +} diff --git a/frontend/src/recommendation-archera-comparison.ts b/frontend/src/recommendation-archera-comparison.ts new file mode 100644 index 00000000..5d5eb7cb --- /dev/null +++ b/frontend/src/recommendation-archera-comparison.ts @@ -0,0 +1,130 @@ +import { getInsuranceStatus, getRecommendationInsuranceComparison } from './api/insurance'; +import type { RecommendationArcheraComparison, RecommendationArcheraOffer } from './api/types'; +import { canAccess } from './permissions'; +import { openModal, closeModal } from './modal'; + +const pending = new Set(); + +const FAILURES: Record = { + 400: 'The recommendation request was invalid.', + 403: 'You are not authorised to view Archera alternatives.', + 404: 'This recommendation no longer exists. Refresh the list.', + 409: 'The recommendation changed or is ambiguous. Refresh the list and try again.', + 422: 'This recommendation type cannot be compared with Archera.', + 502: 'Archera is unavailable or returned an unusable response.', + 503: 'The comparison service is not available right now.', + 504: 'Archera did not respond in time.', +}; + +function failureText(error: unknown): string { + const status = (error as { status?: number } | null)?.status; + return (status !== undefined && FAILURES[status]) || 'Archera alternatives unavailable.'; +} + +function node(tag: K, text?: string): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (text !== undefined) element.textContent = text; + return element; +} + +function offerView(offer: RecommendationArcheraOffer, title: string): HTMLElement { + const section = node('section'); + section.append(node('h3', title)); + const values: Array<[string, string | null]> = [ + ['Offer', offer.offer_id], + ['Provider / product', `${offer.provider} / ${offer.commitment_type}`], + ['Selected hourly commitment', offer.hourly_selected_commitment], + ['Native duration (seconds)', offer.native_duration_seconds], + ['Native plan type', offer.native_plan_type], + ['Native instance family', offer.native_instance_family], + ['Effective contract / lease term', offer.effective_contract_term], + ['Payment', offer.payment_option], + ['Lease attached', offer.lease_attached ? 'Yes' : 'No'], + ['Monthly commitment cost (730 hours)', offer.monthly.monthly_730h_commitment_cost_total], + ['Monthly provider cost (730 hours)', offer.monthly.monthly_730h_cloud_provider_cost], + ['Monthly Archera premium (730 hours)', offer.monthly.monthly_730h_archera_premium], + ['Monthly gross savings (730 hours)', offer.monthly.monthly_730h_gross_savings], + ['Monthly net savings (730 hours)', offer.monthly.monthly_730h_net_savings], + ['Monthly covered on-demand cost (730 hours)', offer.monthly.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', offer.upfront_one_time_cost], + ]; + const list = node('dl'); + for (const [label, value] of values) list.append(node('dt', label), node('dd', value ?? 'Unknown')); + section.append(list); + return section; +} + +function resultView(result: RecommendationArcheraComparison): HTMLElement { + const root = node('div'); + root.append(node('p', result.status === 'resource_associated' + ? 'Resource association found. Comparable economics remain unavailable.' + : `Association unavailable: ${result.reason.replace(/_/g, ' ')}`)); + root.append(node('p', `Fetched at ${result.fetched_at}`)); + root.append(node('p', result.currency_note)); + root.append(node('p', 'Currency: unknown. Comparable cost difference: unavailable.')); + root.append(node('p', 'The stored hourly amount has already passed through floating-point conversion. Original provider precision is unproven; these are native vendor figures, not an equivalent-cost quote.')); + root.append(node('p', `Premium included: ${result.premium_included ? 'Yes' : 'No'}`)); + root.append(node('p', result.non_gating_disclosure), node('p', result.sponsorship_disclosure)); + if (result.current) root.append(offerView(result.current, 'Current vendor offer')); + for (const [index, candidate] of result.candidates.entries()) { + root.append(node('p', `Alternative ${index + 1}: ${candidate.status.replace(/_/g, ' ')}. ${candidate.reason.replace(/_/g, ' ')}`)); + root.append(node('p', 'Alternative native product identity is unavailable; current-offer dimensions are not inherited.')); + if (candidate.figures) root.append(offerView(candidate.figures, `Vendor alternative ${index + 1}`)); + } + return root; +} + +async function compare(id: string, button: HTMLButtonElement): Promise { + if (pending.has(id) || !canAccess('view', 'recommendations')) return; + pending.add(id); + const modal = node('div'); + modal.className = 'modal hidden'; + modal.setAttribute('role', 'dialog'); + modal.setAttribute('aria-modal', 'true'); + modal.setAttribute('aria-label', 'Archera recommendation alternatives'); + const panel = node('div'); + panel.className = 'modal-content modal-wide'; + const close = node('button', 'Close'); + close.type = 'button'; + close.className = 'btn'; + close.addEventListener('click', () => closeModal(modal)); + const content = node('div', 'Loading Archera alternatives'); + content.setAttribute('aria-live', 'polite'); + panel.append(node('h2', 'Archera recommendation alternatives'), close, content); + modal.append(panel); + document.body.append(modal); + openModal(modal, { onClose: () => { button.disabled = false; modal.remove(); } }); + button.disabled = true; + try { + const result = await getRecommendationInsuranceComparison(id); + if (result.recommendation_id !== id) throw Object.assign(new Error('mismatched recommendation'), { status: 409 }); + content.replaceChildren(resultView(result)); + } catch (error) { + content.textContent = failureText(error); + } finally { + pending.delete(id); + button.disabled = false; + } +} + +export async function mountRecommendationArchera(root: HTMLElement): Promise { + if (!canAccess('view', 'recommendations')) return; + try { + if (!(await getInsuranceStatus()).configured) return; + } catch { + return; + } + for (const row of root.querySelectorAll('tr.recommendation-row[data-rec-id]')) { + const id = row.dataset['recId']; + if (!id || row.querySelector('[data-action="archera"]')) continue; + const button = node('button', 'Archera alternatives'); + button.type = 'button'; + button.className = 'btn btn-small'; + button.dataset['action'] = 'archera'; + button.addEventListener('click', event => { + event.stopPropagation(); + void compare(id, button); + }); + row.cells[0]?.append(button); + } +} diff --git a/frontend/src/recommendations.ts b/frontend/src/recommendations.ts index 6f44b1be..bd1b9709 100644 --- a/frontend/src/recommendations.ts +++ b/frontend/src/recommendations.ts @@ -24,6 +24,7 @@ import type { RecommendationsResponse, LocalRecommendation, RecommendationsSumma import { openModal } from './modal'; import { showSkeletonRows, teardownSkeleton } from './lib/skeleton'; import { canAccess } from './permissions'; +import { mountRecommendationArchera } from './recommendation-archera-comparison'; // Issue #869: true when the current session can take any action on // recommendations (purchase or plan). Readonly/viewer sessions have neither @@ -5020,6 +5021,7 @@ function renderRecommendationsList(loadedRecs: LocalRecommendation[]): void { renderRecommendationsList(loadedRecs); }); }); + void mountRecommendationArchera(container); } // Resolve a priced override, valid own payment, or priced legacy fallback. diff --git a/frontend/tests-e2e/recommendation-archera.spec.ts b/frontend/tests-e2e/recommendation-archera.spec.ts new file mode 100644 index 00000000..cf78a29f --- /dev/null +++ b/frontend/tests-e2e/recommendation-archera.spec.ts @@ -0,0 +1,66 @@ +import { test, expect } from '@playwright/test'; +import { mockApi, seedAuth, RECS } from './fixtures/recs'; + +test('readonly stored row opens native Archera figures without selecting or purchasing', async ({ page }) => { + const errors: string[] = []; + page.on('pageerror', error => errors.push(error.message)); + await seedAuth(page); + const { calls } = await mockApi(page); + await page.route('**/api/auth/me', route => route.fulfill({ json: { id: 'viewer', groups: [], email: 'viewer@example.com' } })); + await page.route('**/api/auth/me/permissions', route => route.fulfill({ json: { + permissions: [{ action: 'view', resource: 'recommendations' }], is_admin: false, + } })); + await page.route(/\/api\/recommendations(?:\?.*)?$/, route => route.fulfill({ json: { + recommendations: [{ ...RECS[0], id: 'stored/id', service: 'savings-plans-ec2instance', resource_type: 'm7i' }], + summary: {}, regions: ['us-east-1'], + } })); + await page.route('**/api/insurance/status', route => route.fulfill({ json: { configured: true, missing: [] } })); + const comparisonCalls: string[] = []; + const figures = { + offer_id: '', provider: 'aws', commitment_type: 'savings_plan', + hourly_selected_commitment: '0.500000000000000001', native_duration_seconds: '31536000', + native_plan_type: 'EC2Instance', native_instance_family: 'm7i', effective_contract_term: 'one_year_gris', + payment_option: 'no_upfront', lease_attached: false, upfront_one_time_cost: '1200', + monthly: { monthly_730h_commitment_cost_total: '110', monthly_730h_cloud_provider_cost: '100', + monthly_730h_archera_premium: '10', monthly_730h_gross_savings: null, + monthly_730h_net_savings: '20', monthly_730h_covered_on_demand_cost: '130' }, + }; + await page.route('**/api/insurance/comparison?*', route => { + comparisonCalls.push(route.request().url()); + return route.fulfill({ json: { + recommendation_id: 'stored/id', status: 'resource_associated', reason: '', + amount_provenance: 'stored_decimal_only', economic_status: 'unavailable_source_precision', + fetched_at: '2026-10-15T12:00:00Z', currency: null, comparable_delta: null, + currency_note: 'Vendor currency unavailable', premium_included: true, current: figures, + candidates: [{ offer_id: 'alternative', status: 'vendor_alternative', reason: '', + native_identity_status: 'unavailable', figures: { ...figures, native_duration_seconds: null, + native_plan_type: null, native_instance_family: null, lease_attached: true, upfront_one_time_cost: '2400' } }], + non_gating_disclosure: 'Purchases do not depend on Archera.', sponsorship_disclosure: 'Sponsored by Archera.', + } }); + }); + await page.goto('/opportunities'); + const button = page.getByRole('button', { name: 'Archera alternatives' }); + await expect(button).toBeVisible(); + expect(comparisonCalls).toEqual([]); + await expect(page.getByRole('checkbox', { name: 'Select recommendation', exact: true })).toHaveCount(0); + await button.click(); + const dialog = page.getByRole('dialog', { name: 'Archera recommendation alternatives' }); + await expect(dialog).toContainText('0.500000000000000001'); + await expect(dialog).toContainText('one_year_gris'); + await expect(dialog).toContainText('2400'); + await expect(dialog).toContainText('native product identity is unavailable'); + await expect(dialog).toContainText('Currency: unknown. Comparable cost difference: unavailable.'); + await expect(dialog).toContainText('Original provider precision is unproven'); + await expect(dialog).toContainText('Sponsored by Archera.'); + await expect(dialog.locator('img')).toHaveCount(0); + expect(new URL(comparisonCalls[0]!).searchParams.get('recommendation_id')).toBe('stored/id'); + expect(comparisonCalls).toHaveLength(1); + expect(calls.filter(call => call.method !== 'GET')).toEqual([]); + expect(await page.evaluate(() => (window as unknown as { __archeraXss?: number }).__archeraXss)).toBeUndefined(); + await page.setViewportSize({ width: 390, height: 844 }); + expect(await dialog.evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).toHaveCount(0); + await expect(button).toBeFocused(); + expect(errors).toEqual([]); +}); diff --git a/internal/api/handler_insurance_recommendation_contract_test.go b/internal/api/handler_insurance_recommendation_contract_test.go new file mode 100644 index 00000000..ab545004 --- /dev/null +++ b/internal/api/handler_insurance_recommendation_contract_test.go @@ -0,0 +1,222 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" + "github.com/LeanerCloud/cloud-commitments-platform/internal/secrets" +) + +// The vendor here is synthetic transport behind the real archera.Provider and insurance.Client; it is not +// a live Archera organization. + +const contractOrg = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa" + +type contractSecret struct { + secrets.Resolver + calls int +} + +func (s *contractSecret) GetSecret(context.Context, string) (string, error) { + s.calls++ + return insTestKey, nil +} + +type contractVendor struct { + t *testing.T + scenario string + calls []*url.URL +} + +func contractItem(id, account, payment string) string { + return fmt.Sprintf(`{"id":%q,"plan_id":%q,"offer_id":%q,"account_id":%s,"selected_quantity":7,"selected_commitment":0.5,"is_selected":true,"contract_term":"one_year_gris","payment_option":%q,"contract_spec":{"commitment_type":"x"},"offer":{"id":%q,"type":"savings_plan","provider":"aws","provider_offering_id":"provider-offer-a","offering_id":"cat","duration_seconds":31536000,"region":"us-east-1","az":null,"instance_type":null,"instance_family":"m5","product_description":null,"tenancy":null,"plan_type":"EC2Instance","is_multi_az":null,"is_flexible":null,"payment_option":%q}}`, id, insTestPlan, recOffer, account, payment, recOffer, payment) +} + +func contractUnrelatedID(i int) string { return fmt.Sprintf("00000000-0000-4000-8000-%012d", i) } + +func contractOfferRow(current bool, payment, offer string) string { + return fmt.Sprintf(`{"is_current":%t,"offer_id":%q,"selected_amount":0.5,"commitment_type":"savings_plan","contract_term":"one_year_gris","payment_option":%q,"offer":{"provider":"aws","region":"us-east-1"},"discount_rate":0.1,"commitment_upfront_cost":1200,"commitment_financials_monthly_rate":{},"delta_vs_current":{"monthly_net_savings":5,"upfront_cost":1,"discount_rate":0.1}}`, current, offer, payment) +} + +func contractComparison(scenario string) string { + id := recItem + if scenario == "comparison_missing" { + id = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb" + } + currentPay, candPay := "no_upfront", "partial_upfront" + if scenario == "partial" { + currentPay, candPay = "partial_upfront", "all_upfront" + } + cand := contractOfferRow(false, candPay, "faadf00d-ffff-4aba-8ada-abcdefabcdef") + row := `{"line_item_id":"` + id + `","current":` + contractOfferRow(true, currentPay, recOffer) + `,"candidates":[` + cand + `]}` + unrelated := `{"line_item_id":"` + contractUnrelatedID(1) + `","current":` + strings.Replace(contractOfferRow(true, currentPay, recOffer), `"commitment_upfront_cost":1200`, `"commitment_upfront_cost":99999999`, 1) + `,"candidates":[]}` + return `{"current_totals":{"commitment_financials_monthly_rate":{},"commitment_upfront_cost":987654321},"hypothetical_totals":[],"data":[` + unrelated + `,` + row + `]}` +} + +func (v *contractVendor) RoundTrip(req *http.Request) (*http.Response, error) { + v.calls = append(v.calls, req.URL) + require.Equal(v.t, http.MethodGet, req.Method) + require.Equal(v.t, "https", req.URL.Scheme) + require.Equal(v.t, "api.archera.ai", req.URL.Host) + require.Equal(v.t, insTestKey, req.Header.Get("x-api-key")) + prefix := "/v1/org/" + contractOrg + "/commitment-plans/" + insTestPlan + q := req.URL.Query() + headers := http.Header{} + status, body := http.StatusOK, "" + switch req.URL.Path { + case prefix + "/line-items": + page := q.Get("page") + require.Equal(v.t, "100", q.Get("page_size")) + require.Contains(v.t, []string{"1", "2"}, page) + headers.Set("X-Pagination", `{"total":101,"total_pages":2,"page":`+page+`,"first_page":1,"last_page":2}`) + if page == "1" { + rows := make([]string, 0, 100) + for i := 1; i <= 100; i++ { + rows = append(rows, contractItem(contractUnrelatedID(i), `"unrelated-source"`, "No Upfront")) + } + body = "[" + strings.Join(rows, ",") + "]" + break + } + account := `"Source-account-a"` + if v.scenario == "source_case" { + account = `"source-account-a"` + } + payment := "No Upfront" + if v.scenario == "partial" { + payment = "Partial Upfront" + } + body = "[" + contractItem(recItem, account, payment) + "]" + if v.scenario == "late_failure" { + status, body = http.StatusInternalServerError, `{"message":"`+recSpace+`"}` + } + case prefix + "/comparison": + body = contractComparison(v.scenario) + default: + v.t.Fatalf("unexpected vendor path: %s", req.URL.Path) + } + return &http.Response{StatusCode: status, Header: headers, Body: io.NopCloser(strings.NewReader(body)), Request: req}, nil +} + +func contractRig(t *testing.T, scenario string, rows []config.RecommendationRecord) (*Handler, *recStore, *contractSecret, *contractVendor) { + t.Helper() + store := &recStore{results: [][]config.RecommendationRecord{rows}} + resolver := &contractSecret{} + vendor := &contractVendor{t: t, scenario: scenario} + provider := archera.NewProvider(archera.Settings{OrgID: contractOrg, PlanID: insTestPlan, KeySecretRef: insTestRef}, resolver, &http.Client{Transport: vendor}) + return &Handler{config: store, insurance: provider}, store, resolver, vendor +} + +func contractGet(h *Handler) (any, error) { + return (&recRig{h: h}).getID("stored-a") +} + +func TestInsuranceRecommendationRealClientReadsPageTwoAndScopesComparison(t *testing.T) { + h, store, resolver, vendor := contractRig(t, "complete", one()) + got, err := contractGet(h) + require.NoError(t, err) + dto := got.(*archera.RecommendationComparisonDTO) + require.Equal(t, archera.ResourceAssociated, dto.Status) + require.Equal(t, "stored-a", dto.RecommendationID) + require.Nil(t, dto.Currency) + require.Nil(t, dto.ComparableDelta) + require.Len(t, dto.Candidates, 1) + + require.Len(t, vendor.calls, 3) + prefix := "/v1/org/" + contractOrg + "/commitment-plans/" + insTestPlan + for i, page := range []string{"1", "2"} { + require.Equal(t, prefix+"/line-items", vendor.calls[i].Path) + require.Equal(t, page, vendor.calls[i].Query().Get("page")) + } + cq := vendor.calls[2].Query() + require.Equal(t, prefix+"/comparison", vendor.calls[2].Path) + require.Equal(t, []string{recItem}, cq["line_item_ids"]) + require.ElementsMatch(t, []string{"no_upfront", "partial_upfront", "all_upfront"}, cq["payment_options"]) + require.Equal(t, 1, resolver.calls) + require.Equal(t, 2, store.calls, "stored row read before and after the vendor fetch") + + body, err := json.Marshal(dto) + require.NoError(t, err) + for _, private := range []string{"Source-account-a", "unrelated-source", contractOrg, insTestPlan, "99999999", "987654321", contractUnrelatedID(1)} { + require.NotContains(t, string(body), private) + } +} + +func TestInsuranceRecommendationRealClientPartialUpfrontRecommendationMatches(t *testing.T) { + row := recRecord() + row.Payment = "partial-upfront" + h, _, _, vendor := contractRig(t, "partial", []config.RecommendationRecord{row}) + got, err := contractGet(h) + require.NoError(t, err) + require.Equal(t, archera.ResourceAssociated, got.(*archera.RecommendationComparisonDTO).Status) + require.Len(t, vendor.calls, 3) +} + +func TestInsuranceRecommendationRealClientRefusesPartialOrUnprovenData(t *testing.T) { + for _, scenario := range []string{"late_failure", "comparison_missing", "source_case"} { + t.Run(scenario, func(t *testing.T) { + h, _, _, vendor := contractRig(t, scenario, one()) + got, err := contractGet(h) + if scenario == "late_failure" { + require.Nil(t, got) + ce := requireCode(t, err, 502) + require.NotContains(t, ce.message, recSpace) + require.Len(t, vendor.calls, 2, "no comparison call after a failed page") + return + } + require.NoError(t, err) + dto := got.(*archera.RecommendationComparisonDTO) + require.Equal(t, archera.MatchUnavailable, dto.Status) + require.Nil(t, dto.Current) + require.Empty(t, dto.Candidates) + }) + } +} + +func TestInsuranceRecommendationRealClientRowChangedAfterFetchIs409(t *testing.T) { + h, store, _, vendor := contractRig(t, "complete", one()) + changed := recRecord() + changed.Details = []byte(strings.Replace(string(changed.Details), "0.5", "0.6", 1)) + store.results = append(store.results, []config.RecommendationRecord{changed}) + got, err := contractGet(h) + require.Nil(t, got) + requireCode(t, err, 409) + require.Len(t, vendor.calls, 3) +} + +func TestInsuranceRecommendationRealClientStoreFailuresMakeNoVendorCalls(t *testing.T) { + for name, rows := range map[string][]config.RecommendationRecord{"duplicate_store": append(one(), recRecord()), "missing": nil} { + t.Run(name, func(t *testing.T) { + h, _, resolver, vendor := contractRig(t, "complete", rows) + _, err := contractGet(h) + require.Error(t, err) + require.Zero(t, resolver.calls) + require.Empty(t, vendor.calls) + }) + } +} + +func TestInsuranceRecommendationRealClientUnauthorizedMakesNoVendorCalls(t *testing.T) { + for _, scoped := range []bool{false, true} { + h, store, resolver, vendor := contractRig(t, "complete", one()) + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(scoped, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return([]string{"acct-1"}, nil) + h.auth = m + _, err := (&recRig{h: h}).get(sessionPrincipalCtx(), "recommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}) + require.Error(t, err) + require.Zero(t, store.calls) + require.Zero(t, resolver.calls) + require.Empty(t, vendor.calls) + } +}