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.go b/internal/api/handler_insurance_recommendation.go new file mode 100644 index 00000000..d58176b7 --- /dev/null +++ b/internal/api/handler_insurance_recommendation.go @@ -0,0 +1,117 @@ +package api + +import ( + "context" + "encoding/json" + "net/url" + "regexp" + "strings" + + "github.com/aws/aws-lambda-go/events" + + "github.com/LeanerCloud/cloud-commitments-platform/internal/archera" + "github.com/LeanerCloud/cloud-commitments-platform/internal/config" +) + +const ( + recommendationQueryKey = "recommendation_id" + maxRecommendationIDBytes = 1024 +) + +var recommendationQuerySplit = regexp.MustCompile(`[&;]`) + +// rawRecommendationKeyCount counts raw query occurrences of the key after unescaping, so an encoded or +// ";"-separated spelling cannot slip past the map the adapters build. +func rawRecommendationKeyCount(raw string) int { + n := 0 + if raw == "" { + return 0 + } + for _, part := range recommendationQuerySplit.Split(raw, -1) { + key, _, _ := strings.Cut(part, "=") + if decoded, err := url.QueryUnescape(key); err == nil && decoded == recommendationQueryKey { + n++ + } + } + return n +} + +func insuranceRecommendationRequested(req *events.LambdaFunctionURLRequest) bool { + _, ok := req.QueryStringParameters[recommendationQueryKey] + return ok || rawRecommendationKeyCount(req.RawQueryString) > 0 +} + +func insuranceRecommendationID(req *events.LambdaFunctionURLRequest) (string, error) { + id := req.QueryStringParameters[recommendationQueryKey] + if n := rawRecommendationKeyCount(req.RawQueryString); n > 1 { + return "", NewClientError(400, "invalid recommendation query") + } + if id == "" || len(id) > maxRecommendationIDBytes { + return "", NewClientError(400, "invalid recommendation identifier") + } + return id, nil +} + +// getInsuranceRecommendation compares one stored recommendation with its Archera line item. +// The auth gate and the exact-one store read come before any secret or vendor work. +func (h *Handler) getInsuranceRecommendation(ctx context.Context, req *events.LambdaFunctionURLRequest) (*archera.RecommendationComparisonDTO, error) { + if err := h.requireInsuranceAccess(ctx, req); err != nil { + return nil, err + } + id, err := insuranceRecommendationID(req) + if err != nil { + return nil, err + } + record, err := h.storedRecommendation(ctx, id) + if err != nil { + return nil, err + } + rec, err := archera.ProjectRecommendation(record) + if err != nil { + return nil, NewClientError(422, "stored recommendation is not comparable") + } + c, items, err := h.FetchRecommendationComparison(ctx, rec) + if err != nil { + return nil, err + } + if !h.recommendationUnchanged(ctx, id, rec) { + return nil, errRecommendationChanged + } + dto, err := archera.BuildRecommendationComparison(id, h.insurance.PlanID(), rec, c, items) + if err != nil { + return nil, NewClientError(502, "Archera recommendation comparison is unavailable") + } + raw, err := json.Marshal(dto) + if err != nil || len(raw) > maxComparisonResponseBytes { + return nil, NewClientError(502, "comparison too large to return") + } + return dto, nil +} + +// recommendationUnchanged rereads on the caller's context; any change, deletion or store error is a change. +func (h *Handler) recommendationUnchanged(ctx context.Context, id string, rec *archera.RecommendationIdentity) bool { + again, err := h.storedRecommendation(ctx, id) + if err != nil { + return false + } + now, err := archera.ProjectRecommendation(again) + return err == nil && rec.Equal(now) +} + +var errRecommendationChanged = NewClientError(409, "recommendation changed during comparison") + +func (h *Handler) storedRecommendation(ctx context.Context, id string) (*config.RecommendationRecord, error) { + if h.config == nil { + return nil, NewClientError(503, "recommendation store is unavailable") + } + rows, err := h.config.ListStoredRecommendations(ctx, config.RecommendationFilter{ID: id}) + switch { + case err != nil: + return nil, NewClientError(503, "recommendation store is unavailable") + case len(rows) == 0: + return nil, errNotFound + case len(rows) > 1: + return nil, NewClientError(409, "recommendation identity is ambiguous") + } + return &rows[0], nil +} 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) + } +} diff --git a/internal/api/handler_insurance_recommendation_test.go b/internal/api/handler_insurance_recommendation_test.go new file mode 100644 index 00000000..755ee582 --- /dev/null +++ b/internal/api/handler_insurance_recommendation_test.go @@ -0,0 +1,257 @@ +package api + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/LeanerCloud/cloud-commitments-go/pkg/insurance" + "github.com/aws/aws-lambda-go/events" + "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" +) + +// recStore serves one scripted result per ListStoredRecommendations call; the last one repeats. +type recStore struct { + config.StoreInterface + results [][]config.RecommendationRecord + errs []error + calls int + filter config.RecommendationFilter +} + +func (s *recStore) ListStoredRecommendations(_ context.Context, f config.RecommendationFilter) ([]config.RecommendationRecord, error) { + i := min(s.calls, len(s.results)-1) + s.calls++ + s.filter = f + var err error + if s.calls <= len(s.errs) { + err = s.errs[s.calls-1] + } + return s.results[i], err +} + +type recRig struct { + store *recStore + client *recClient + provider *recProvider + h *Handler +} + +func newRecRig(t *testing.T, results ...[]config.RecommendationRecord) *recRig { + t.Helper() + items, cmp := recVendor(t) + c := &recClient{items: items, cmp: cmp} + p := &recProvider{client: c} + s := &recStore{results: results} + return &recRig{s, c, p, &Handler{config: s, insurance: p}} +} + +func (r *recRig) get(ctx context.Context, raw string, params map[string]string) (any, error) { + return NewRouter(r.h).Route(ctx, "GET", "/api/insurance/comparison", &events.LambdaFunctionURLRequest{RawQueryString: raw, QueryStringParameters: params}) +} + +func (r *recRig) getID(id string) (any, error) { + return r.get(adminCtx(), "recommendation_id="+id, map[string]string{"recommendation_id": id}) +} + +func requireCode(t *testing.T, err error, code int) *clientError { + t.Helper() + ce, ok := IsClientError(err) + require.True(t, ok, "got %v", err) + require.Equal(t, code, ce.code) + require.NotContains(t, ce.message, recSpace) + return ce +} + +func one() []config.RecommendationRecord { r := recRecord(); return []config.RecommendationRecord{r} } + +func TestInsuranceRecommendationMatchesThroughRouter(t *testing.T) { + r := newRecRig(t, one()) + got, err := r.getID("stored-a") + require.NoError(t, err) + dto, ok := got.(*archera.RecommendationComparisonDTO) + require.True(t, ok, "got %T", got) + require.Equal(t, archera.ResourceAssociated, dto.Status) + require.Equal(t, "stored-a", dto.RecommendationID) + require.Equal(t, "stored-a", r.store.filter.ID) + require.Equal(t, 2, r.store.calls) + require.Equal(t, []string{recItem}, r.client.request.LineItemIDs) + require.ElementsMatch(t, []insurance.PaymentOption{insurance.PaymentNoUpfront, insurance.PaymentPartialUpfront, insurance.PaymentAllUpfront}, r.client.request.PaymentOptions) + require.Equal(t, insTestPlan, r.client.request.PlanID) +} + +func TestInsuranceRecommendationNilCloudAccountStillCompares(t *testing.T) { + row := recRecord() + row.CloudAccountID = nil + r := newRecRig(t, []config.RecommendationRecord{row}) + got, err := r.getID("stored-a") + require.NoError(t, err) + require.Equal(t, archera.ResourceAssociated, got.(*archera.RecommendationComparisonDTO).Status) +} + +func TestInsuranceRecommendationAbsentParamKeepsPlanComparison(t *testing.T) { + r := newRecRig(t, one()) + r.client.cmp = &insurance.Comparison{} + got, err := r.get(adminCtx(), "unrelated=1", map[string]string{"unrelated": "1"}) + require.NoError(t, err) + _, ok := got.(*archera.ComparisonDTO) + require.True(t, ok, "got %T", got) + require.Zero(t, r.store.calls) + require.Zero(t, r.client.itemCalls) +} + +func TestInsuranceRecommendationNoLineItemIsUnavailableWithoutComparison(t *testing.T) { + r := newRecRig(t, one()) + r.client.items = nil + got, err := r.getID("stored-a") + require.NoError(t, err) + dto := got.(*archera.RecommendationComparisonDTO) + require.Equal(t, archera.MatchUnavailable, dto.Status) + require.Nil(t, dto.Current) + require.Zero(t, r.client.cmpCalls) +} + +func TestInsuranceRecommendationDeniedOrScopedMakesNoCalls(t *testing.T) { + for name, allowed := range map[string]struct { + permission bool + accounts []string + }{"denied": {false, nil}, "scoped": {true, []string{"acct-1"}}} { + t.Run(name, func(t *testing.T) { + m := new(MockAuthService) + m.On("HasPermissionAPI", mock.Anything, "u1", "view", "recommendations").Return(allowed.permission, nil) + m.On("GetAllowedAccountsAPI", mock.Anything, "u1").Return(allowed.accounts, nil) + r := newRecRig(t, one()) + r.h.auth = m + _, err := r.get(sessionPrincipalCtx(), "recommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}) + require.Error(t, err) + require.Zero(t, r.store.calls) + require.Zero(t, r.provider.keys) + require.Zero(t, r.client.itemCalls) + }) + } +} + +func TestInsuranceRecommendationStoreOutcomesPrecedeVendorWork(t *testing.T) { + for name, tc := range map[string]struct { + rows []config.RecommendationRecord + err error + code int + }{ + "missing": {nil, nil, 404}, + "duplicate": {append(one(), recRecord()), nil, 409}, + "error": {nil, errors.New(recSpace), 503}, + "bad_hourly": {func() []config.RecommendationRecord { + row := recRecord() + row.Details = []byte(`{"offering_id":"x","plan_type":"EC2Instance","region":"r","instance_family":"m5"}`) + return []config.RecommendationRecord{row} + }(), nil, 422}, + } { + t.Run(name, func(t *testing.T) { + r := newRecRig(t, tc.rows) + r.store.errs = []error{tc.err} + _, err := r.getID("stored-a") + if tc.code == 404 { + require.ErrorIs(t, err, errNotFound) + } else { + requireCode(t, err, tc.code) + } + require.Zero(t, r.provider.keys) + require.Zero(t, r.client.itemCalls) + require.Equal(t, 1, r.store.calls) + }) + } +} + +func TestInsuranceRecommendationRereadChangeDeletionOrErrorIs409(t *testing.T) { + changed := recRecord() + changed.Details = []byte(strings.Replace(string(changed.Details), "0.5", "0.6", 1)) + other := recRecord() + other.Payment = "all-upfront" + for name, tc := range map[string]struct { + after []config.RecommendationRecord + err error + }{ + "hourly": {[]config.RecommendationRecord{changed}, nil}, "payment": {[]config.RecommendationRecord{other}, nil}, + "deleted": {nil, nil}, "duplicate": {append(one(), recRecord()), nil}, "error": {nil, errors.New(recSpace)}, + } { + t.Run(name, func(t *testing.T) { + r := newRecRig(t, one(), tc.after) + r.store.errs = []error{nil, tc.err} + got, err := r.getID("stored-a") + require.Nil(t, got) + requireCode(t, err, 409) + }) + } +} + +func TestInsuranceRecommendationVendorFailures(t *testing.T) { + for name, tc := range map[string]struct { + setup func(*recRig) + code int + retry string + }{ + "line_items": {func(r *recRig) { r.client.itemsErr = errors.New(recSpace) }, 502, ""}, + "comparison": {func(r *recRig) { r.client.cmpErr = errors.New(recSpace) }, 502, ""}, + "rate_limited": {func(r *recRig) { + r.client.itemsErr = &insurance.HTTPError{StatusCode: 429, RetryAfter: 3 * time.Second} + }, 429, "3"}, + "unsupported": {func(r *recRig) { r.provider.client = quoteOnly{r.client} }, 503, ""}, + "deadline": {func(r *recRig) { r.client.hang = true }, 504, ""}, + } { + t.Run(name, func(t *testing.T) { + prev := insuranceRecommendationTimeout + insuranceRecommendationTimeout = 50 * time.Millisecond + t.Cleanup(func() { insuranceRecommendationTimeout = prev }) + r := newRecRig(t, one()) + tc.setup(r) + got, err := r.getID("stored-a") + require.Nil(t, got) + ce := requireCode(t, err, tc.code) + if tc.retry != "" { + require.Equal(t, tc.retry, ce.headers["Retry-After"]) + } + require.Equal(t, 1, r.store.calls, "no reread after a vendor failure") + }) + } +} + +func TestInsuranceRecommendationQueryRules(t *testing.T) { + long := strings.Repeat("x", 1025) + for name, tc := range map[string]struct { + raw string + params map[string]string + code int + }{ + "repeated": {"recommendation_id=stored-a&recommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}, 400}, + "encoded_repeated": {"recommendation_id=stored-a&%72ecommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}, 400}, + "semicolon": {"recommendation_id=stored-a;recommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}, 400}, + "raw_map_missing": {"recommendation_id=stored-a", nil, 400}, + "encoded_key_alone": {"%72ecommendation_id=stored-a", map[string]string{}, 400}, + "empty": {"recommendation_id=", map[string]string{"recommendation_id": ""}, 400}, + "too_long": {"recommendation_id=" + long, map[string]string{"recommendation_id": long}, 400}, + "ok": {"recommendation_id=stored-a", map[string]string{"recommendation_id": "stored-a"}, 0}, + } { + t.Run(name, func(t *testing.T) { + r := newRecRig(t, one()) + got, err := r.get(adminCtx(), tc.raw, tc.params) + if tc.code == 0 { + require.NoError(t, err) + require.IsType(t, &archera.RecommendationComparisonDTO{}, got) + return + } + requireCode(t, err, tc.code) + require.Zero(t, r.store.calls) + require.Zero(t, r.provider.keys) + }) + } +} + +func (s *recStore) ListCloudAccounts(context.Context, config.CloudAccountFilter) ([]config.CloudAccount, error) { + return nil, nil +} diff --git a/internal/api/router.go b/internal/api/router.go index 9b5ba7b4..8716ea17 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -521,6 +521,9 @@ func (r *Router) getInsuranceStatusHandler(ctx context.Context, req *events.Lamb } func (r *Router) getInsuranceComparisonHandler(ctx context.Context, req *events.LambdaFunctionURLRequest, params map[string]string) (any, error) { + if insuranceRecommendationRequested(req) { + return r.h.getInsuranceRecommendation(ctx, req) + } return r.h.getInsuranceComparison(ctx, req) }