diff --git a/src/__tests__/e2e/payments-stripe-elements.test.tsx b/src/__tests__/e2e/payments-stripe-elements.test.tsx
new file mode 100644
index 0000000..9c05470
--- /dev/null
+++ b/src/__tests__/e2e/payments-stripe-elements.test.tsx
@@ -0,0 +1,550 @@
+/**
+ * E2E Tests: Stripe Elements Integration (Frontend)
+ *
+ * Epic: OQI-005 - Payments & Stripe
+ * Blocker: BLOCKER-002 (ST4.2)
+ *
+ * Tests validate:
+ * - Stripe Elements renders correctly (iframe)
+ * - Card data NEVER stored in React state
+ * - confirmCardPayment called with clientSecret
+ * - NO card data sent to backend
+ * - Payment confirmation happens via Stripe
+ */
+
+import React from 'react';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js';
+import { loadStripe } from '@stripe/stripe-js';
+import '@testing-library/jest-dom';
+
+// Mock Stripe
+jest.mock('@stripe/stripe-js');
+jest.mock('@stripe/react-stripe-js', () => ({
+ ...jest.requireActual('@stripe/react-stripe-js'),
+ useStripe: jest.fn(),
+ useElements: jest.fn(),
+ CardElement: jest.fn(() =>
Stripe Card Element
),
+}));
+
+// Mock API client
+jest.mock('../../lib/apiClient', () => ({
+ apiClient: {
+ post: jest.fn(),
+ },
+}));
+
+import { apiClient } from '../../lib/apiClient';
+import DepositForm from '../../modules/investment/components/DepositForm';
+
+describe('E2E: Stripe Elements Integration (Frontend)', () => {
+ let mockStripe: any;
+ let mockElements: any;
+ let mockCardElement: any;
+
+ beforeEach(() => {
+ // Setup Stripe mocks
+ mockCardElement = {
+ mount: jest.fn(),
+ unmount: jest.fn(),
+ on: jest.fn(),
+ update: jest.fn(),
+ };
+
+ mockElements = {
+ getElement: jest.fn(() => mockCardElement),
+ };
+
+ mockStripe = {
+ confirmCardPayment: jest.fn(),
+ elements: jest.fn(() => mockElements),
+ };
+
+ (useStripe as jest.Mock).mockReturnValue(mockStripe);
+ (useElements as jest.Mock).mockReturnValue(mockElements);
+ (loadStripe as jest.Mock).mockResolvedValue(mockStripe);
+
+ // Clear all mocks
+ jest.clearAllMocks();
+ });
+
+ // ==========================================================================
+ // TEST 1: Stripe CardElement Renders (Iframe)
+ // ==========================================================================
+
+ describe('Stripe CardElement Rendering', () => {
+ it('should render Stripe CardElement (NOT native input)', () => {
+ render(
+
+
+
+ );
+
+ // Verify Stripe CardElement is rendered
+ const cardElement = screen.getByTestId('stripe-card-element');
+ expect(cardElement).toBeInTheDocument();
+
+ // CRITICAL: Verify NO native card inputs
+ expect(screen.queryByPlaceholderText(/card number/i)).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText(/cvv/i)).not.toBeInTheDocument();
+ expect(screen.queryByPlaceholderText(/expiry/i)).not.toBeInTheDocument();
+ });
+
+ it('should NOT store card data in React state', () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Search for any state variables that might store card data
+ const componentHTML = container.innerHTML;
+
+ // ❌ Prohibited: Card data in DOM
+ expect(componentHTML).not.toContain('cardNumber');
+ expect(componentHTML).not.toContain('cvv');
+ expect(componentHTML).not.toContain('expiryDate');
+ expect(componentHTML).not.toContain('4242424242424242'); // Example card number
+ });
+ });
+
+ // ==========================================================================
+ // TEST 2: Payment Intent Flow (Backend Integration)
+ // ==========================================================================
+
+ describe('Payment Intent Flow', () => {
+ it('should create Payment Intent and confirm with Stripe', async () => {
+ // Mock backend response (Payment Intent)
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ clientSecret: 'pi_test_123_secret_456',
+ paymentIntentId: 'pi_test_123',
+ },
+ },
+ });
+
+ // Mock Stripe confirmCardPayment success
+ mockStripe.confirmCardPayment.mockResolvedValue({
+ paymentIntent: {
+ id: 'pi_test_123',
+ status: 'succeeded',
+ },
+ error: null,
+ });
+
+ render(
+
+
+
+ );
+
+ // Step 1: Fill amount
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '100' } });
+
+ // Step 2: Submit form
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ // Wait for async operations
+ await waitFor(() => {
+ // Verify Payment Intent created (backend call)
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/v1/payments/wallet/deposit',
+ expect.objectContaining({
+ amount: 100,
+ currency: 'USD',
+ })
+ );
+
+ // CRITICAL: Verify NO card data sent to backend
+ const backendCall = (apiClient.post as jest.Mock).mock.calls[0][1];
+ expect(backendCall).not.toHaveProperty('cardNumber');
+ expect(backendCall).not.toHaveProperty('cvv');
+ expect(backendCall).not.toHaveProperty('expiryDate');
+ });
+
+ // Verify Stripe confirmCardPayment called
+ await waitFor(() => {
+ expect(mockStripe.confirmCardPayment).toHaveBeenCalledWith(
+ 'pi_test_123_secret_456',
+ expect.objectContaining({
+ payment_method: {
+ card: mockCardElement, // ← Card element (iframe)
+ },
+ })
+ );
+ });
+
+ // Verify success message
+ await waitFor(() => {
+ expect(screen.getByText(/deposit successful/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle Stripe card validation errors', async () => {
+ // Mock Stripe confirmCardPayment failure (invalid card)
+ mockStripe.confirmCardPayment.mockResolvedValue({
+ error: {
+ type: 'card_error',
+ code: 'invalid_number',
+ message: 'Your card number is invalid.',
+ },
+ paymentIntent: null,
+ });
+
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ clientSecret: 'pi_test_456_secret_789',
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ // Fill and submit
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '50' } });
+
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ // Verify error displayed
+ await waitFor(() => {
+ expect(screen.getByText(/your card number is invalid/i)).toBeInTheDocument();
+ });
+
+ // CRITICAL: Verify error came from Stripe, not our validation
+ // (This proves card validation happens in Stripe iframe, not our code)
+ expect(mockStripe.confirmCardPayment).toHaveBeenCalled();
+ });
+ });
+
+ // ==========================================================================
+ // TEST 3: Checkout Session Flow (Stripe Hosted Page)
+ // ==========================================================================
+
+ describe('Checkout Session Flow (Stripe Hosted)', () => {
+ it('should redirect to Stripe hosted checkout page', async () => {
+ // Mock backend response (Checkout Session)
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ sessionId: 'cs_test_123',
+ url: 'https://checkout.stripe.com/pay/cs_test_123',
+ },
+ },
+ });
+
+ // Mock window.location.href
+ delete (window as any).location;
+ (window as any).location = { href: '' };
+
+ render(
+
+
+
+
+
+ );
+
+ const subscribeButton = screen.getByRole('button', { name: /subscribe/i });
+ fireEvent.click(subscribeButton);
+
+ await waitFor(() => {
+ // Verify redirected to Stripe hosted page
+ expect(window.location.href).toBe('https://checkout.stripe.com/pay/cs_test_123');
+
+ // CRITICAL: Verify redirect is to Stripe domain (not our domain)
+ expect(window.location.href).toContain('checkout.stripe.com');
+ });
+
+ // CRITICAL: Verify NO card input on our page
+ // (User will enter card data on Stripe's hosted page, not ours)
+ expect(screen.queryByTestId('stripe-card-element')).not.toBeInTheDocument();
+ });
+ });
+
+ // ==========================================================================
+ // TEST 4: Payment Method Attachment (Tokenization)
+ // ==========================================================================
+
+ describe('Payment Method Attachment', () => {
+ it('should attach payment method using Stripe token', async () => {
+ // Mock Stripe createPaymentMethod (tokenization)
+ mockStripe.createPaymentMethod = jest.fn().mockResolvedValue({
+ paymentMethod: {
+ id: 'pm_test_123',
+ type: 'card',
+ card: {
+ brand: 'visa',
+ last4: '4242',
+ },
+ },
+ error: null,
+ });
+
+ // Mock backend attach payment method
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ paymentMethodId: 'pm_test_123',
+ },
+ },
+ });
+
+ render(
+
+
+
+
+
+
+ );
+
+ const addCardButton = screen.getByRole('button', { name: /add card/i });
+ fireEvent.click(addCardButton);
+
+ await waitFor(() => {
+ // Verify Stripe createPaymentMethod called (tokenization)
+ expect(mockStripe.createPaymentMethod).toHaveBeenCalledWith({
+ type: 'card',
+ card: mockCardElement,
+ });
+
+ // Verify backend received token (NOT raw card)
+ expect(apiClient.post).toHaveBeenCalledWith(
+ '/api/v1/payments/methods',
+ expect.objectContaining({
+ paymentMethodId: 'pm_test_123', // ✅ Token
+ })
+ );
+
+ // CRITICAL: Verify NO raw card data sent
+ const backendCall = (apiClient.post as jest.Mock).mock.calls[0][1];
+ expect(backendCall).not.toHaveProperty('cardNumber');
+ expect(backendCall).not.toHaveProperty('cvv');
+ expect(backendCall).not.toHaveProperty('expiryDate');
+ });
+ });
+ });
+
+ // ==========================================================================
+ // TEST 5: Component State Validation (No Sensitive Data)
+ // ==========================================================================
+
+ describe('Component State: No Sensitive Data', () => {
+ it('should NOT have card data in component state', () => {
+ const { container } = render(
+
+
+
+ );
+
+ // Get component props/state from DOM
+ const componentText = container.textContent || '';
+
+ // ❌ Prohibited: Card data in component state
+ expect(componentText).not.toContain('4242424242424242'); // Card number
+ expect(componentText).not.toContain('cvv');
+ expect(componentText).not.toContain('cvc');
+
+ // Search for useState/useReducer with card data
+ // (This would appear in React DevTools, but we can't easily test here)
+ // Manual verification required: Check React DevTools
+ });
+ });
+
+ // ==========================================================================
+ // TEST 6: Error Handling (Stripe Errors)
+ // ==========================================================================
+
+ describe('Error Handling', () => {
+ it('should display Stripe validation errors', async () => {
+ // Mock Stripe error (card declined)
+ mockStripe.confirmCardPayment.mockResolvedValue({
+ error: {
+ type: 'card_error',
+ code: 'card_declined',
+ message: 'Your card was declined.',
+ },
+ paymentIntent: null,
+ });
+
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ clientSecret: 'pi_test_error',
+ },
+ },
+ });
+
+ render(
+
+
+
+ );
+
+ // Submit form
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '100' } });
+
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ // Verify error displayed
+ await waitFor(() => {
+ expect(screen.getByText(/your card was declined/i)).toBeInTheDocument();
+ });
+ });
+
+ it('should handle network errors gracefully', async () => {
+ // Mock network error
+ (apiClient.post as jest.Mock).mockRejectedValue(new Error('Network error'));
+
+ render(
+
+
+
+ );
+
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '100' } });
+
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(screen.getByText(/network error/i)).toBeInTheDocument();
+ });
+ });
+ });
+
+ // ==========================================================================
+ // TEST 7: Security Best Practices
+ // ==========================================================================
+
+ describe('Security Best Practices', () => {
+ it('should use HTTPS for all API calls', async () => {
+ // Mock successful payment
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ clientSecret: 'pi_test_https',
+ },
+ },
+ });
+
+ mockStripe.confirmCardPayment.mockResolvedValue({
+ paymentIntent: { id: 'pi_test_https', status: 'succeeded' },
+ error: null,
+ });
+
+ render(
+
+
+
+ );
+
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '100' } });
+
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ // In production, verify API base URL is HTTPS
+ // (Mock doesn't have URL, but implementation should enforce HTTPS)
+ expect(apiClient.post).toHaveBeenCalled();
+ });
+ });
+
+ it('should NOT log sensitive data to console', async () => {
+ // Spy on console methods
+ const consoleLogSpy = jest.spyOn(console, 'log');
+ const consoleErrorSpy = jest.spyOn(console, 'error');
+
+ (apiClient.post as jest.Mock).mockResolvedValue({
+ data: {
+ success: true,
+ data: {
+ clientSecret: 'pi_test_console',
+ },
+ },
+ });
+
+ mockStripe.confirmCardPayment.mockResolvedValue({
+ paymentIntent: { id: 'pi_test_console', status: 'succeeded' },
+ error: null,
+ });
+
+ render(
+
+
+
+ );
+
+ const amountInput = screen.getByLabelText(/amount/i);
+ fireEvent.change(amountInput, { target: { value: '100' } });
+
+ const submitButton = screen.getByRole('button', { name: /deposit/i });
+ fireEvent.click(submitButton);
+
+ await waitFor(() => {
+ expect(mockStripe.confirmCardPayment).toHaveBeenCalled();
+ });
+
+ // Verify NO sensitive data logged
+ const allLogs = [
+ ...consoleLogSpy.mock.calls,
+ ...consoleErrorSpy.mock.calls,
+ ].flat().join(' ');
+
+ expect(allLogs).not.toContain('4242424242424242'); // Card number
+ expect(allLogs).not.toContain('pi_test_console_secret'); // Client secret
+ expect(allLogs).not.toContain('cvv');
+
+ consoleLogSpy.mockRestore();
+ consoleErrorSpy.mockRestore();
+ });
+ });
+});