From 143ef9219dfff0fb2c16d8af82ddd1cb5a16486f Mon Sep 17 00:00:00 2001 From: Mystery Date: Mon, 1 Jun 2026 11:29:57 +0000 Subject: [PATCH 1/4] fix(#708): document circuit breaker multi-admin vote-to-unpause in RUNBOOK.md Adds Section 2 covering the three-phase circuit breaker procedure: trigger emergency pause, coordinate vote-to-unpause quorum, and use emergency_unpause as a last-resort break-glass action. Renumbers subsequent sections accordingly. Co-Authored-By: Claude Sonnet 4.6 --- RUNBOOK.md | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 106 insertions(+), 6 deletions(-) diff --git a/RUNBOOK.md b/RUNBOOK.md index bd597d50..5a0d8ba8 100644 --- a/RUNBOOK.md +++ b/RUNBOOK.md @@ -48,7 +48,107 @@ Confirm `paused: true` and `pause_reason` matches the reason supplied. --- -## 2. Unpause After Incident Resolution +## 2. Circuit Breaker: Multi-Admin Vote-to-Unpause + +The circuit breaker is a quorum-gated safety mechanism that prevents a single compromised admin from unilaterally resuming contract operations after an emergency pause. All three phases below must be completed in order. + +### Phase 1 — Trigger the emergency pause + +Any single admin can pause immediately (see **Section 1** for the full procedure): + +```bash +soroban contract invoke \ + --id $CONTRACT_ID \ + --source $ADMIN_IDENTITY \ + --network $NETWORK \ + -- \ + emergency_pause \ + --caller $ADMIN_ADDRESS \ + --reason SecurityIncident +``` + +Notify all other admins in `#incidents` immediately so they can participate in the quorum vote. + +### Phase 2 — Coordinate the vote-to-unpause quorum + +**Check current quorum state** (run this before and after each vote): + +```bash +soroban contract invoke \ + --id $CONTRACT_ID \ + --source $ADMIN_IDENTITY \ + --network $NETWORK \ + -- \ + health +``` + +Inspect the response for: +- `paused: true` — confirms the pause is active +- `pause_votes` — number of admins who have already voted to unpause +- `required_votes` — quorum threshold that must be reached +- `timelock_remaining` — seconds remaining before the timelock expires (must reach 0 before unpause is accepted) + +**Each admin must cast a vote independently:** + +```bash +soroban contract invoke \ + --id $CONTRACT_ID \ + --source $ADMIN_IDENTITY \ + --network $NETWORK \ + -- \ + vote_unpause \ + --caller $ADMIN_ADDRESS +``` + +Repeat this command for every admin until `pause_votes >= required_votes`. Once quorum is reached **and** the timelock has elapsed, the contract unpauses automatically. + +**Tracking votes during a live incident:** + +1. Designate one admin as incident commander to collect confirmation messages in `#incidents`. +2. Each admin posts their `ADMIN_ADDRESS` and transaction hash after voting. +3. The incident commander re-runs the `health` query after each vote to confirm `pause_votes` increments. +4. Do not proceed to Phase 3 until `pause_votes >= required_votes` is confirmed in `health` output. + +### Phase 3 — Emergency unpause (last resort only) + +Use `emergency_unpause` **only** when: +- Quorum cannot be reached (e.g., admins are unreachable), **and** +- The situation requires immediate contract resumption to prevent greater harm, **and** +- A post-incident review will be conducted to address the quorum failure. + +```bash +soroban contract invoke \ + --id $CONTRACT_ID \ + --source $ADMIN_IDENTITY \ + --network $NETWORK \ + -- \ + emergency_unpause \ + --caller $ADMIN_ADDRESS +``` + +> **Warning:** `emergency_unpause` bypasses quorum. It should be treated as a break-glass action. Document the justification in the incident GitHub issue before executing. + +Verify the contract is running normally after either path: + +```bash +soroban contract invoke \ + --id $CONTRACT_ID \ + --source $ADMIN_IDENTITY \ + --network $NETWORK \ + -- \ + health +``` + +Confirm `paused: false` and `pause_votes: 0` (votes are cleared on unpause). + +**After completing the circuit breaker procedure:** +- Close the incident GitHub issue with a summary of which path was taken (quorum or last-resort). +- Post a resolution notice in `#incidents` including the ledger sequence of the unpause. +- If `emergency_unpause` was used, open a follow-up issue tagged `security-review` to evaluate whether the admin quorum configuration needs adjustment. + +--- + +## 3. Unpause After Incident Resolution Unpausing requires admin quorum votes (default: 1). If a timelock is configured, the elapsed time since the pause must exceed `timelock_seconds` before the unpause is accepted. @@ -97,7 +197,7 @@ Confirm `paused: false`. --- -## 3. Rotate Admin Keys via Governance Proposal +## 4. Rotate Admin Keys via Governance Proposal Admin key rotation uses the on-chain governance module. The process is: propose → vote → execute (after timelock). @@ -169,7 +269,7 @@ soroban contract invoke \ --- -## 4. Handle a Stuck Migration +## 5. Handle a Stuck Migration A migration can become stuck if a batch import fails mid-flight or the contract is paused during migration. @@ -229,7 +329,7 @@ soroban contract invoke \ --- -## 5. Replay Failed Webhook Deliveries +## 6. Replay Failed Webhook Deliveries The webhook dispatcher persists delivery attempts in the `webhook_deliveries` table. Failed deliveries can be replayed via the backend admin API. @@ -283,7 +383,7 @@ psql $DATABASE_URL -c " --- -## 6. Extend Contract Storage TTL +## 7. Extend Contract Storage TTL Soroban persistent storage entries expire after a set number of ledgers. Extend TTL before entries expire to avoid data loss. @@ -326,7 +426,7 @@ Recommended: run a scheduled job (weekly) to bump TTL on all active remittances --- -## 7. Escalation Contacts and SLA Targets +## 8. Escalation Contacts and SLA Targets | Severity | Definition | Response SLA | Resolution SLA | Escalation Path | |----------|-----------|-------------|----------------|-----------------| From a185108d1d6bc587c20f2a1fa45786f7bf12e2ae Mon Sep 17 00:00:00 2001 From: Mystery Date: Mon, 1 Jun 2026 11:30:21 +0000 Subject: [PATCH 2/4] fix(#707): add network timeout simulation and tests to MockSep24AnchorServer Adds enableTimeoutSimulation()/disableTimeoutSimulation() to MockSep24AnchorServer so individual endpoints can be made to hang indefinitely. Adds a 'Timeout Handling' describe block with four tests verifying deposit, withdrawal, poll, and error-message behaviour when the anchor server does not respond. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/__tests__/sep24-service.test.ts | 142 +++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/backend/src/__tests__/sep24-service.test.ts b/backend/src/__tests__/sep24-service.test.ts index 2134b30b..04493b52 100644 --- a/backend/src/__tests__/sep24-service.test.ts +++ b/backend/src/__tests__/sep24-service.test.ts @@ -56,6 +56,22 @@ class MockSep24AnchorServer { private server: http.Server | null = null; private port: number = 0; private transactions: Map = new Map(); + private timeoutEnabled: boolean = false; + private timeoutPaths: Set = new Set(); + + enableTimeoutSimulation(paths: string[] = ['/sep24/deposit', '/sep24/withdraw', '/sep24/transaction']): void { + this.timeoutEnabled = true; + paths.forEach((p) => this.timeoutPaths.add(p)); + } + + disableTimeoutSimulation(): void { + this.timeoutEnabled = false; + this.timeoutPaths.clear(); + } + + private shouldTimeout(path: string): boolean { + return this.timeoutEnabled && this.timeoutPaths.has(path); + } async start(): Promise { this.app = express(); @@ -63,6 +79,10 @@ class MockSep24AnchorServer { // Mock /deposit endpoint (SEP-24) this.app.post('/sep24/deposit', (req: Request, res: Response) => { + if (this.shouldTimeout('/sep24/deposit')) { + // Never respond — simulates a network timeout + return; + } const { transaction_id, asset_code, amount } = req.body; if (!transaction_id || !asset_code || !amount) { @@ -86,6 +106,9 @@ class MockSep24AnchorServer { // Mock /withdraw endpoint (SEP-24) this.app.post('/sep24/withdraw', (req: Request, res: Response) => { + if (this.shouldTimeout('/sep24/withdraw')) { + return; + } const { transaction_id, asset_code, amount } = req.body; if (!transaction_id || !asset_code || !amount) { @@ -106,6 +129,9 @@ class MockSep24AnchorServer { // Mock /transaction endpoint (SEP-24 status query) this.app.get('/sep24/transaction', (req: Request, res: Response) => { + if (this.shouldTimeout('/sep24/transaction')) { + return; + } const { id } = req.query; if (!id) { @@ -395,10 +421,10 @@ describe('Error Handling', () => { it('should handle anchor connection error', async () => { process.env.SEP24_SERVER_ANCHOR_TEST = 'http://localhost:9999/nonexistent'; - + const service = new Sep24Service(pool); await service.initialize(); - + const request: Sep24InitiateRequest = { user_id: 'test-user-123', anchor_id: 'anchor_test', @@ -409,4 +435,116 @@ describe('Error Handling', () => { await expect(service.initiateFlow(request)).rejects.toThrow(); }); +}); + +describe('Timeout Handling', () => { + let mockServer: MockSep24AnchorServer; + let serverUrl: string; + let pool: Pool; + + beforeEach(async () => { + mockServer = new MockSep24AnchorServer(); + serverUrl = await mockServer.start(); + pool = createMockPool(); + + process.env.SEP24_ENABLED_ANCHOR_TEST = 'true'; + process.env.SEP24_SERVER_ANCHOR_TEST = serverUrl + '/sep24'; + process.env.SEP24_POLL_INTERVAL_ANCHOR_TEST = '1'; + // Very short timeout (1 ms) so the hanging server triggers a timeout error + process.env.SEP24_TIMEOUT_ANCHOR_TEST = '1'; + resetSep24Rows(); + }); + + afterEach(async () => { + mockServer.disableTimeoutSimulation(); + await mockServer.stop(); + resetSep24Rows(); + vi.clearAllMocks(); + }); + + it('should reject deposit initiation with a timeout error when the anchor does not respond', async () => { + mockServer.enableTimeoutSimulation(['/sep24/deposit']); + + const service = new Sep24Service(pool); + await service.initialize(); + + const request: Sep24InitiateRequest = { + user_id: 'timeout-user', + anchor_id: 'anchor_test', + direction: 'deposit', + asset_code: 'USDC', + amount: '100.00', + }; + + await expect(service.initiateFlow(request)).rejects.toThrow(); + }); + + it('should reject withdrawal initiation with a timeout error when the anchor does not respond', async () => { + mockServer.enableTimeoutSimulation(['/sep24/withdraw']); + + const service = new Sep24Service(pool); + await service.initialize(); + + const request: Sep24InitiateRequest = { + user_id: 'timeout-user', + anchor_id: 'anchor_test', + direction: 'withdrawal', + asset_code: 'USDC', + amount: '50.00', + user_address: 'GAXXX', + }; + + await expect(service.initiateFlow(request)).rejects.toThrow(); + }); + + it('should handle timeout during transaction status polling gracefully', async () => { + const service = new Sep24Service(pool); + await service.initialize(); + + // Initiate successfully before enabling timeout + const request: Sep24InitiateRequest = { + user_id: 'poll-timeout-user', + anchor_id: 'anchor_test', + direction: 'deposit', + asset_code: 'USDC', + amount: '75.00', + }; + await service.initiateFlow(request); + + // Now make the transaction status endpoint hang + mockServer.enableTimeoutSimulation(['/sep24/transaction']); + + // pollAllTransactions should not throw — it must handle the timeout internally + await expect(service.pollAllTransactions()).resolves.not.toThrow(); + }); + + it('should report an appropriate error message on timeout, not a generic network error', async () => { + mockServer.enableTimeoutSimulation(['/sep24/deposit']); + + const service = new Sep24Service(pool); + await service.initialize(); + + const request: Sep24InitiateRequest = { + user_id: 'error-msg-user', + anchor_id: 'anchor_test', + direction: 'deposit', + asset_code: 'USDC', + amount: '200.00', + }; + + let caughtError: unknown; + try { + await service.initiateFlow(request); + } catch (err) { + caughtError = err; + } + + expect(caughtError).toBeDefined(); + // The error should be an Error instance with a meaningful message + expect(caughtError).toBeInstanceOf(Error); + const message = (caughtError as Error).message.toLowerCase(); + expect( + message.includes('timeout') || message.includes('timed out') || message.includes('time') || message.includes('abort') || message.includes('network') + ).toBe(true); + }); }); \ No newline at end of file From 73d48c6a2879f9c4b98e63422c3da89a0f1ab601 Mon Sep 17 00:00:00 2001 From: Mystery Date: Mon, 1 Jun 2026 11:30:37 +0000 Subject: [PATCH 3/4] fix(#705): add jest-axe accessibility checks to all frontend component tests Adds jest-axe as a dev dependency and configures it in test-setup.ts. Adds axe() a11y assertions in dedicated 'accessibility' describe blocks across all eleven component test files, covering loading, error, connected, and interactive states. Co-Authored-By: Claude Sonnet 4.6 --- frontend/package.json | 2 + .../__tests__/AnchorSelector.test.tsx | 44 +++++++++++++++++ .../__tests__/AnchorSelectorKeyboard.test.tsx | 30 ++++++++++++ .../__tests__/ErrorBoundary.test.jsx | 25 ++++++++++ .../__tests__/ProofOfPayout.test.tsx | 26 ++++++++++ .../__tests__/SendMoneyFlow.test.tsx | 20 ++++++++ .../__tests__/SendMoneyFlowMemo.test.tsx | 18 +++++++ .../TransactionHistory.pagination.test.tsx | 22 +++++++++ .../__tests__/TransactionHistoryMemo.test.tsx | 23 +++++++++ .../TransactionStatusTracker.test.tsx | 49 +++++++++++++++++++ .../__tests__/VerificationBadge.test.tsx | 37 ++++++++++++++ .../__tests__/WalletConnection.test.tsx | 23 +++++++++ frontend/src/test-setup.ts | 9 ++++ 13 files changed, 328 insertions(+) diff --git a/frontend/package.json b/frontend/package.json index dd637c8b..a4e9ce81 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,6 +24,8 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^14.1.2", "@testing-library/user-event": "^14.5.1", + "jest-axe": "^9.0.0", + "@types/jest-axe": "^3.5.9", "@typescript-eslint/parser": "^7.18.0", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/frontend/src/components/__tests__/AnchorSelector.test.tsx b/frontend/src/components/__tests__/AnchorSelector.test.tsx index 50509eb1..d39a2afa 100644 --- a/frontend/src/components/__tests__/AnchorSelector.test.tsx +++ b/frontend/src/components/__tests__/AnchorSelector.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { AnchorSelector, AnchorProvider } from '../AnchorSelector'; +expect.extend(toHaveNoViolations); + describe('AnchorSelector', () => { const mockAnchor: AnchorProvider = { id: 'anchor-1', @@ -222,4 +225,45 @@ describe('AnchorSelector', () => { expect(screen.getByText('⭐ 4.8')).toBeInTheDocument(); }); }); + + describe('accessibility', () => { + it('has no a11y violations in loading state', async () => { + (global.fetch as any).mockImplementation(() => new Promise(() => {})); + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in error state', async () => { + (global.fetch as any).mockRejectedValueOnce(new Error('Network error')); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Failed to connect to anchor service')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when anchor list is rendered', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, data: [mockAnchor, mockAnchor2] }), + }); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Choose an anchor provider...')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when dropdown is open', async () => { + (global.fetch as any).mockResolvedValueOnce({ + ok: true, + json: async () => ({ success: true, data: [mockAnchor] }), + }); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Choose an anchor provider...')).toBeInTheDocument()); + fireEvent.click(screen.getByText('Choose an anchor provider...')); + await waitFor(() => expect(screen.getByText('Test Anchor')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/AnchorSelectorKeyboard.test.tsx b/frontend/src/components/__tests__/AnchorSelectorKeyboard.test.tsx index 77295878..19ff4fbb 100644 --- a/frontend/src/components/__tests__/AnchorSelectorKeyboard.test.tsx +++ b/frontend/src/components/__tests__/AnchorSelectorKeyboard.test.tsx @@ -1,9 +1,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; import '@testing-library/jest-dom'; import { AnchorSelector, AnchorProvider } from '../AnchorSelector'; +expect.extend(toHaveNoViolations); + const mockAnchors: AnchorProvider[] = [ { id: 'anchor-1', @@ -317,4 +320,31 @@ describe('AnchorSelector Keyboard Navigation', () => { }); }); }); + + describe('accessibility', () => { + it('has no a11y violations with the trigger button rendered', async () => { + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: mockAnchors }), + }); + const { container } = render(); + await waitFor(() => screen.getByRole('button', { name: /select anchor provider/i })); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when the listbox is open', async () => { + const user = userEvent.setup(); + global.fetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ success: true, data: mockAnchors }), + }); + const { container } = render(); + const trigger = await waitFor(() => screen.getByRole('button', { name: /select anchor provider/i })); + await user.click(trigger); + await waitFor(() => screen.getByRole('listbox')); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/ErrorBoundary.test.jsx b/frontend/src/components/__tests__/ErrorBoundary.test.jsx index 97a1a1f1..8f32d70b 100644 --- a/frontend/src/components/__tests__/ErrorBoundary.test.jsx +++ b/frontend/src/components/__tests__/ErrorBoundary.test.jsx @@ -1,7 +1,10 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { render, screen, fireEvent } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import ErrorBoundary from '../ErrorBoundary'; +expect.extend(toHaveNoViolations); + const ThrowError = () => { throw new Error('Test error'); }; @@ -153,4 +156,26 @@ describe('ErrorBoundary', () => { expect(screen.getByText('Normal content')).toBeInTheDocument(); expect(screen.queryByText('Something Went Wrong')).not.toBeInTheDocument(); }); + + describe('accessibility', () => { + it('has no a11y violations in error fallback state', async () => { + const { container } = render( + + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when rendering normal children', async () => { + const { container } = render( + + + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); \ No newline at end of file diff --git a/frontend/src/components/__tests__/ProofOfPayout.test.tsx b/frontend/src/components/__tests__/ProofOfPayout.test.tsx index 656fd930..05ab1949 100644 --- a/frontend/src/components/__tests__/ProofOfPayout.test.tsx +++ b/frontend/src/components/__tests__/ProofOfPayout.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { ProofOfPayout } from '../ProofOfPayout'; import * as horizonServiceModule from '../../services/horizonService'; +expect.extend(toHaveNoViolations); + // Mock the horizon service vi.mock('../../services/horizonService', () => ({ horizonService: { @@ -162,4 +165,27 @@ describe('ProofOfPayout', () => { expect(screen.getByText(/Capture an image as proof/)).toBeInTheDocument(); }); }); + + describe('accessibility', () => { + it('has no a11y violations in loading state', async () => { + vi.mocked(horizonServiceModule.horizonService.fetchCompletedEvent).mockReturnValue(new Promise(() => {})); + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when proof data is displayed', async () => { + vi.mocked(horizonServiceModule.horizonService.fetchCompletedEvent).mockResolvedValue({ + remittanceId: '42', + transactionHash: 'abc123', + amount: '100', + recipient: 'GXXXXXX', + timestamp: '2026-01-01T00:00:00Z', + }); + const { container } = render(); + await waitFor(() => expect(screen.queryByText(/loading/i)).not.toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/SendMoneyFlow.test.tsx b/frontend/src/components/__tests__/SendMoneyFlow.test.tsx index 11f86cbe..2c2e4240 100644 --- a/frontend/src/components/__tests__/SendMoneyFlow.test.tsx +++ b/frontend/src/components/__tests__/SendMoneyFlow.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { SendMoneyFlow } from '../SendMoneyFlow'; +expect.extend(toHaveNoViolations); + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -386,4 +389,21 @@ describe('SendMoneyFlow', () => { expect(screen.queryByRole('option', { name: 'XLM' })).not.toBeInTheDocument(); }); }); + + describe('accessibility', () => { + it('has no a11y violations on initial render', async () => { + const { container } = render(); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations on the confirmation step', async () => { + const { container } = render(); + fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '100' } }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + await waitFor(() => expect(screen.getByRole('button', { name: /confirm/i })).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/SendMoneyFlowMemo.test.tsx b/frontend/src/components/__tests__/SendMoneyFlowMemo.test.tsx index c4a2e59b..0aff5f26 100644 --- a/frontend/src/components/__tests__/SendMoneyFlowMemo.test.tsx +++ b/frontend/src/components/__tests__/SendMoneyFlowMemo.test.tsx @@ -13,8 +13,11 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { SendMoneyFlow } from '../SendMoneyFlow'; +expect.extend(toHaveNoViolations); + const VALID_RECIPIENT = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA'; afterEach(() => { @@ -141,4 +144,19 @@ describe('SendMoneyFlow – memo field', () => { // Step 4 – memo row should not appear expect(screen.queryByText(/^memo$/i)).not.toBeInTheDocument(); }); + + describe('accessibility', () => { + it('has no a11y violations on the memo step', async () => { + const VALID_RECIPIENT_MEMO = 'GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNA'; + const { container } = render(); + fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '50' } }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + fireEvent.change(screen.getByLabelText(/asset/i), { target: { value: 'USDC' } }); + fireEvent.click(screen.getByRole('button', { name: /continue/i })); + fireEvent.change(screen.getByLabelText(/recipient/i), { target: { value: VALID_RECIPIENT_MEMO } }); + await waitFor(() => expect(screen.getByLabelText(/memo/i)).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/TransactionHistory.pagination.test.tsx b/frontend/src/components/__tests__/TransactionHistory.pagination.test.tsx index aa26dbb2..0236f3da 100644 --- a/frontend/src/components/__tests__/TransactionHistory.pagination.test.tsx +++ b/frontend/src/components/__tests__/TransactionHistory.pagination.test.tsx @@ -1,9 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { TransactionHistory, TransactionHistoryItem } from '../TransactionHistory'; import '@testing-library/jest-dom'; +expect.extend(toHaveNoViolations); + const mockTransactions: TransactionHistoryItem[] = Array.from({ length: 25 }, (_, i) => ({ id: `tx-${i}`, amount: 100 + i, @@ -200,4 +203,23 @@ describe('TransactionHistory Pagination', () => { expect(screen.getByText(/Page 1 of 3/)).toBeInTheDocument(); expect(screen.getByText(/Showing 1–10 of 25 transactions/)).toBeInTheDocument(); }); + + describe('accessibility', () => { + it('has no a11y violations on first page', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations on second page', async () => { + const { container } = render( + + ); + fireEvent.click(screen.getByLabelText('Next page')); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/TransactionHistoryMemo.test.tsx b/frontend/src/components/__tests__/TransactionHistoryMemo.test.tsx index 8d62afc7..5a080e0a 100644 --- a/frontend/src/components/__tests__/TransactionHistoryMemo.test.tsx +++ b/frontend/src/components/__tests__/TransactionHistoryMemo.test.tsx @@ -9,9 +9,12 @@ import { describe, it, expect, afterEach } from 'vitest'; import { render, screen, fireEvent, cleanup } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { TransactionHistory } from '../TransactionHistory'; import type { TransactionHistoryItem } from '../TransactionHistory'; +expect.extend(toHaveNoViolations); + afterEach(cleanup); const BASE_TX: TransactionHistoryItem = { @@ -89,4 +92,24 @@ describe('TransactionHistory – loading state', () => { expect(screen.getByText('Loading more transactions...')).toBeInTheDocument(); expect(document.querySelector('[aria-busy="true"]')).toBeNull(); }); + + describe('accessibility', () => { + it('has no a11y violations with transaction list', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations with memo visible', async () => { + const tx = { ...BASE_TX, memo: 'Invoice #1234' }; + const { container } = render( + + ); + fireEvent.click(screen.getByRole('button', { name: /expand/i })); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/TransactionStatusTracker.test.tsx b/frontend/src/components/__tests__/TransactionStatusTracker.test.tsx index 178eef29..9655dcb8 100644 --- a/frontend/src/components/__tests__/TransactionStatusTracker.test.tsx +++ b/frontend/src/components/__tests__/TransactionStatusTracker.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { TransactionStatusTracker, TransactionProgressStatus } from '../TransactionStatusTracker'; +expect.extend(toHaveNoViolations); + describe('TransactionStatusTracker', () => { beforeEach(() => { vi.useFakeTimers(); @@ -662,3 +665,49 @@ describe('TransactionStatusTracker', () => { vi.useFakeTimers(); }); }); + + describe('accessibility', () => { + it('has no a11y violations in initiated state', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in processing state', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in completed state', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in failed state', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations when refresh button is present', async () => { + const { container } = render( + + ); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); diff --git a/frontend/src/components/__tests__/VerificationBadge.test.tsx b/frontend/src/components/__tests__/VerificationBadge.test.tsx index f377cc34..82f76a72 100644 --- a/frontend/src/components/__tests__/VerificationBadge.test.tsx +++ b/frontend/src/components/__tests__/VerificationBadge.test.tsx @@ -1,7 +1,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { VerificationBadge, VerificationStatus, AssetVerification } from '../VerificationBadge'; +expect.extend(toHaveNoViolations); + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -422,4 +425,38 @@ describe('VerificationBadge', () => { ); }); }); + + describe('accessibility', () => { + it('has no a11y violations in verified state', async () => { + mockFetchOnce({ ok: true, status: 200, json: async () => makeVerification() }); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Verified')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in unverified state', async () => { + mockFetchOnce({ ok: false, status: 404 }); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Unverified')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in loading state', () => { + (global.fetch as ReturnType).mockReturnValueOnce(new Promise(() => {})); + const { container } = render(); + return axe(container).then((results) => expect(results).toHaveNoViolations()); + }); + + it('has no a11y violations when details modal is open', async () => { + mockFetchOnce({ ok: true, status: 200, json: async () => makeVerification() }); + const { container } = render(); + await waitFor(() => expect(screen.getByText('Verified')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /asset verification status/i })); + await waitFor(() => expect(screen.getByText('Asset Verification Details')).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/components/__tests__/WalletConnection.test.tsx b/frontend/src/components/__tests__/WalletConnection.test.tsx index 3a0b13d9..e521e5ae 100644 --- a/frontend/src/components/__tests__/WalletConnection.test.tsx +++ b/frontend/src/components/__tests__/WalletConnection.test.tsx @@ -1,8 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { axe, toHaveNoViolations } from 'jest-axe'; import { WalletConnection } from '../WalletConnection'; import * as freighterApi from '@stellar/freighter-api'; +expect.extend(toHaveNoViolations); + // Mock the Freighter API vi.mock('@stellar/freighter-api', () => ({ isConnected: vi.fn(), @@ -539,4 +542,24 @@ describe('WalletConnection', () => { expect(freighterApi.isConnected).not.toHaveBeenCalled(); }); }); + + describe('accessibility', () => { + it('has no a11y violations in disconnected state', async () => { + vi.mocked(freighterApi.isConnected).mockResolvedValue({ isConnected: false }); + const { container } = render(); + await waitFor(() => expect(screen.queryByText(/connecting/i)).not.toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('has no a11y violations in connected state', async () => { + vi.mocked(freighterApi.isConnected).mockResolvedValue({ isConnected: true }); + vi.mocked(freighterApi.getAddress).mockResolvedValue({ address: MOCK_PUBLIC_KEY }); + vi.mocked(freighterApi.getNetwork).mockResolvedValue({ network: 'testnet', networkPassphrase: '' }); + const { container } = render(); + await waitFor(() => expect(screen.getByText(/connected/i)).toBeInTheDocument()); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); }); diff --git a/frontend/src/test-setup.ts b/frontend/src/test-setup.ts index 7b0828bf..d1385d14 100644 --- a/frontend/src/test-setup.ts +++ b/frontend/src/test-setup.ts @@ -1 +1,10 @@ import '@testing-library/jest-dom'; +import { configureAxe } from 'jest-axe'; + +configureAxe({ + rules: { + 'color-contrast': { enabled: true }, + 'label': { enabled: true }, + 'aria-required-attr': { enabled: true }, + }, +}); From 860b8e4ca411505af8bdec03516257be8d790246 Mon Sep 17 00:00:00 2001 From: Mystery Date: Mon, 1 Jun 2026 11:30:52 +0000 Subject: [PATCH 4/4] fix(#706): add proptest property-based tests for netting algorithm invariants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three proptest-driven tests verifying: 1. Fee preservation — total fees in net transfers equal total fees in input remittances 2. Net amount correctness — net equals |sum(A->B) - sum(B->A)| for a single pair 3. Order independence — reversing input order produces identical net transfers Uses proptest to generate random amounts/fees; soroban Addresses are created inside each test body since they require an Env instance. Co-Authored-By: Claude Sonnet 4.6 --- src/netting.rs | 193 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) diff --git a/src/netting.rs b/src/netting.rs index 12f257dc..40e4ab55 100644 --- a/src/netting.rs +++ b/src/netting.rs @@ -245,6 +245,7 @@ pub fn validate_net_settlement( #[cfg(test)] mod tests { use super::*; + use proptest::prelude::*; use soroban_sdk::{testutils::Address as _, Env}; #[test] @@ -546,4 +547,196 @@ mod tests { assert_eq!(t1.total_fees, t2.total_fees); } } + + // ------------------------------------------------------------------------- + // Helper: build a Remittance with minimal fields for property tests + // ------------------------------------------------------------------------- + + fn make_remittance( + env: &Env, + id: u64, + sender: soroban_sdk::Address, + agent: soroban_sdk::Address, + amount: i128, + fee: i128, + ) -> Remittance { + Remittance { + id, + sender, + agent, + amount, + fee, + status: RemittanceStatus::Pending, + expiry: None, + settlement_config: crate::MaybeSettlementConfig::None, + token: soroban_sdk::Address::generate(env), + created_at: 0, + failed_at: None, + dispute_evidence: None, + } + } + + // ------------------------------------------------------------------------- + // Property-based tests using proptest + // + // Addresses cannot be generated by proptest strategies directly (they + // require a soroban Env), so we generate plain-data parameters and create + // the soroban objects inside each test body. proptest drives the data; + // soroban drives the objects. + // ------------------------------------------------------------------------- + + proptest! { + /// Invariant 1 — Fee preservation + /// + /// The sum of fees across all net transfers equals the sum of fees + /// across all pending remittances, regardless of how many remittances + /// are given or what amounts they carry. + #[test] + fn prop_fees_preserved( + // Generate up to 8 (amount, fee) pairs for A->B remittances + ab_pairs in prop::collection::vec((1i128..=500_000i128, 1i128..=1_000i128), 0..8), + // Generate up to 8 (amount, fee) pairs for B->A remittances + ba_pairs in prop::collection::vec((1i128..=500_000i128, 1i128..=1_000i128), 0..8), + ) { + let env = Env::default(); + let addr_a = soroban_sdk::Address::generate(&env); + let addr_b = soroban_sdk::Address::generate(&env); + + let mut remittances = Vec::new(&env); + let mut expected_fees: i128 = 0; + let mut id: u64 = 1; + + for (amount, fee) in &ab_pairs { + remittances.push_back(make_remittance(&env, id, addr_a.clone(), addr_b.clone(), *amount, *fee)); + expected_fees += fee; + id += 1; + } + for (amount, fee) in &ba_pairs { + remittances.push_back(make_remittance(&env, id, addr_b.clone(), addr_a.clone(), *amount, *fee)); + expected_fees += fee; + id += 1; + } + + // Skip oversized batches (MAX_NETTING_BATCH_SIZE enforcement is tested elsewhere) + prop_assume!(remittances.len() <= crate::config::MAX_NETTING_BATCH_SIZE); + + let result = compute_net_settlements(&env, &remittances).unwrap(); + + let actual_fees: i128 = (0..result.net_transfers.len()) + .map(|i| result.net_transfers.get_unchecked(i).total_fees) + .sum(); + + prop_assert_eq!( + actual_fees, + expected_fees, + "fee sum mismatch: expected {expected_fees}, got {actual_fees}" + ); + } + + /// Invariant 2 — Net amount correctness + /// + /// For a single pair (A, B), the net amount equals the absolute + /// difference between the sum of A->B flows and the sum of B->A flows. + /// When the two sums are equal the result should be a zero-transfer + /// (which is elided, leaving an empty transfers list). + #[test] + fn prop_net_amount_correct_for_single_pair( + ab_amounts in prop::collection::vec(1i128..=500_000i128, 0..6), + ba_amounts in prop::collection::vec(1i128..=500_000i128, 0..6), + ) { + let env = Env::default(); + let addr_a = soroban_sdk::Address::generate(&env); + let addr_b = soroban_sdk::Address::generate(&env); + + let mut remittances = Vec::new(&env); + let mut id: u64 = 1; + + let ab_sum: i128 = ab_amounts.iter().sum(); + let ba_sum: i128 = ba_amounts.iter().sum(); + + for &amount in &ab_amounts { + remittances.push_back(make_remittance(&env, id, addr_a.clone(), addr_b.clone(), amount, 0)); + id += 1; + } + for &amount in &ba_amounts { + remittances.push_back(make_remittance(&env, id, addr_b.clone(), addr_a.clone(), amount, 0)); + id += 1; + } + + prop_assume!(remittances.len() <= crate::config::MAX_NETTING_BATCH_SIZE); + + let result = compute_net_settlements(&env, &remittances).unwrap(); + let expected_net = (ab_sum - ba_sum).abs(); + + if expected_net == 0 { + // Fully offset — no transfer emitted + prop_assert_eq!(result.net_transfers.len(), 0); + } else { + prop_assert_eq!(result.net_transfers.len(), 1); + let transfer = result.net_transfers.get_unchecked(0); + prop_assert_eq!( + transfer.net_amount.abs(), + expected_net, + "net amount mismatch: expected {expected_net}" + ); + } + } + + /// Invariant 3 — Order independence + /// + /// Shuffling the input remittances must not change the set of net + /// transfers. We verify this by reversing the input and comparing + /// the resulting net amounts and fee totals for each unique party pair. + #[test] + fn prop_order_independence( + ab_pairs in prop::collection::vec((1i128..=100_000i128, 1i128..=500i128), 1..5), + ba_pairs in prop::collection::vec((1i128..=100_000i128, 1i128..=500i128), 1..5), + ) { + let env = Env::default(); + let addr_a = soroban_sdk::Address::generate(&env); + let addr_b = soroban_sdk::Address::generate(&env); + + let mut forward = Vec::new(&env); + let mut reversed = Vec::new(&env); + let mut id: u64 = 1; + + // Build forward order: all A->B then all B->A + for &(amount, fee) in &ab_pairs { + forward.push_back(make_remittance(&env, id, addr_a.clone(), addr_b.clone(), amount, fee)); + id += 1; + } + for &(amount, fee) in &ba_pairs { + forward.push_back(make_remittance(&env, id, addr_b.clone(), addr_a.clone(), amount, fee)); + id += 1; + } + + // Build reversed order: all B->A then all A->B + let mut id2: u64 = 1; + for &(amount, fee) in &ba_pairs { + reversed.push_back(make_remittance(&env, id2, addr_b.clone(), addr_a.clone(), amount, fee)); + id2 += 1; + } + for &(amount, fee) in &ab_pairs { + reversed.push_back(make_remittance(&env, id2, addr_a.clone(), addr_b.clone(), amount, fee)); + id2 += 1; + } + + prop_assume!(forward.len() <= crate::config::MAX_NETTING_BATCH_SIZE); + + let r1 = compute_net_settlements(&env, &forward).unwrap(); + let r2 = compute_net_settlements(&env, &reversed).unwrap(); + + prop_assert_eq!(r1.net_transfers.len(), r2.net_transfers.len(), + "transfer count differs between orderings"); + + if r1.net_transfers.len() > 0 { + let t1 = r1.net_transfers.get_unchecked(0); + let t2 = r2.net_transfers.get_unchecked(0); + prop_assert_eq!(t1.net_amount, t2.net_amount, + "net_amount differs between orderings"); + prop_assert_eq!(t1.total_fees, t2.total_fees, + "total_fees differ between orderings"); + } + } + } }