diff --git a/frontend/src/__tests__/xss-purchase-payment.test.ts b/frontend/src/__tests__/xss-purchase-payment.test.ts
new file mode 100644
index 000000000..ba5cba738
--- /dev/null
+++ b/frontend/src/__tests__/xss-purchase-payment.test.ts
@@ -0,0 +1,199 @@
+/**
+ * Regression tests for stored XSS via purchase.payment rendered unescaped
+ * inside the planned-purchases term cell (issue #1633).
+ *
+ * plans.ts:renderPlannedPurchaseRow used to emit:
+ *
${purchase.term}yr ${purchase.payment.replace('-', ' ')} |
+ *
+ * Every sibling string field in the same row (plan_name, service,
+ * resource_type, region, status) is escaped with the existing
+ * escapeHtml() from utils.ts -- see xss-purchase-status.test.ts (#445) --
+ * but `payment` was the one field left raw. `payment` is typed as a bare
+ * `string` in api/types.ts (not a closed union like `status`), so nothing
+ * on the frontend constrains its content. The fix wraps the interpolation
+ * with escapeHtml() and defaults a missing value to '' so the unguarded
+ * .replace() call on a null/undefined payment no longer throws.
+ */
+
+import { loadPlans } from '../plans';
+
+// Use the real escapeHtml so the DOM-based escaping is exercised, not a stub.
+jest.mock('../utils', () => {
+ const actual = jest.requireActual('../utils');
+ return {
+ ...actual,
+ formatDate: jest.fn((val: string) => val ? new Date(val).toLocaleDateString() : ''),
+ formatTerm: jest.fn((years: number | null | undefined) =>
+ years == null ? '' : `${years} Year${years === 1 ? '' : 's'}`),
+ formatRampSchedule: jest.fn((val: string) => val || 'Unknown'),
+ formatCurrency: jest.fn((val: number) => `$${val || 0}`),
+ populateAccountFilter: jest.fn(() => Promise.resolve()),
+ };
+});
+
+jest.mock('../api', () => ({
+ getPlans: jest.fn(),
+ getPlannedPurchases: jest.fn(),
+ getPlan: jest.fn(),
+ createPlan: jest.fn(),
+ updatePlan: jest.fn(),
+ patchPlan: jest.fn(),
+ deletePlan: jest.fn(),
+ runPlannedPurchase: jest.fn(),
+ pausePlannedPurchase: jest.fn(),
+ resumePlannedPurchase: jest.fn(),
+ deletePlannedPurchase: jest.fn(),
+ createPlannedPurchases: jest.fn(),
+ listPlanAccounts: jest.fn().mockResolvedValue([]),
+ setPlanAccounts: jest.fn().mockResolvedValue(undefined),
+ listAccounts: jest.fn().mockResolvedValue([]),
+}));
+
+jest.mock('../state', () => ({
+ getRecommendations: jest.fn().mockReturnValue([]),
+ getSelectedRecommendationIDs: jest.fn().mockReturnValue(new Set()),
+ getVisibleRecommendations: jest.fn().mockReturnValue([]),
+ setVisibleRecommendations: jest.fn(),
+ getCurrentProvider: jest.fn().mockReturnValue(''),
+ setCurrentProvider: jest.fn(),
+ getCurrentAccountIDs: jest.fn().mockReturnValue([]),
+ setCurrentAccountIDs: jest.fn(),
+ subscribeProvider: jest.fn().mockReturnValue(() => {}),
+ subscribeAccount: jest.fn().mockReturnValue(() => {}),
+ getCurrentUser: jest.fn().mockReturnValue({ id: 'u-admin', email: 'admin@example.com', groups: ['00000000-0000-5000-8000-000000000001'] }),
+ getPlansColumnFilters: jest.fn().mockReturnValue({}),
+ setPlansColumnFilter: jest.fn(),
+ clearAllPlansColumnFilters: jest.fn(),
+}));
+
+jest.mock('../history', () => ({
+ viewPlanHistory: jest.fn(),
+}));
+
+jest.mock('../commitmentOptions', () => ({
+ populateTermSelect: jest.fn(),
+ populatePaymentSelect: jest.fn(),
+ isValidCombination: jest.fn().mockReturnValue(true),
+ normalizePaymentValue: jest.fn((value: unknown) => value),
+}));
+
+jest.mock('../toast', () => ({
+ showToast: jest.fn(() => ({ dismiss: jest.fn() })),
+}));
+
+jest.mock('../confirmDialog', () => ({
+ confirmDialog: jest.fn(() => Promise.resolve(true)),
+}));
+
+jest.mock('../modal', () => ({
+ openModal: jest.fn(),
+ closeModal: jest.fn(),
+}));
+
+jest.mock('../archera', () => ({
+ openArcheraOfferModal: jest.fn(),
+}));
+
+jest.mock('../lib/skeleton', () => ({
+ showSkeletonTiles: jest.fn(),
+ showSkeletonRows: jest.fn(),
+ teardownSkeleton: jest.fn(),
+}));
+
+jest.mock('../permissions', () => ({
+ canAccess: jest.fn().mockReturnValue(true),
+}));
+
+import * as api from '../api';
+
+const SCRIPT_PAYLOAD = '';
+const IMG_PAYLOAD = '">
';
+
+function makePurchase(payment: string | undefined) {
+ const purchase: Record = {
+ id: 'pp-1',
+ plan_id: 'plan-1',
+ plan_name: 'Test Plan',
+ scheduled_date: '2024-06-01',
+ provider: 'aws',
+ service: 'ec2',
+ resource_type: 't3.medium',
+ region: 'us-east-1',
+ count: 1,
+ term: 1,
+ payment,
+ estimated_savings: 50,
+ upfront_cost: 0,
+ status: 'pending',
+ step_number: 1,
+ total_steps: 1,
+ };
+ if (payment === undefined) delete purchase['payment'];
+ return purchase;
+}
+
+describe('XSS regression: purchase.payment rendered as text, not markup (#1633)', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+ `;
+ jest.clearAllMocks();
+ (api.getPlans as jest.Mock).mockResolvedValue({ plans: [] });
+ });
+
+ test('script-tag payload in purchase.payment does not create a ';
+const IMG_PAYLOAD = '">
';
+
+function makeRec(payment: string) {
+ return {
+ id: 'rec-1',
+ provider: 'aws',
+ service: 'ec2',
+ resource_type: 't3.medium',
+ region: 'us-east-1',
+ count: 1,
+ term: 1,
+ payment,
+ savings: 100,
+ upfront_cost: 500,
+ };
+}
+
+describe('XSS regression: rec.payment rendered as text, not markup (#1632)', () => {
+ beforeEach(() => {
+ document.body.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+ jest.clearAllMocks();
+ jest.useFakeTimers();
+ (api.getRecommendations as jest.Mock).mockResolvedValue({ summary: {}, recommendations: [], regions: [] });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ test('script-tag payload in rec.payment does not create a