Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions docs/STORAGE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# Browser storage

Everything Parashield keeps in `localStorage` / `sessionStorage`. All key names
are defined in [`src/lib/storageKeys.ts`](../src/lib/storageKeys.ts); nothing
else in `src/` may use a raw string key (enforced by
`src/__tests__/storageKeys.test.ts`).

## Rules

1. **One namespace.** Every first-party key starts with `ps_`, so the app's
whole footprint can be audited and cleared by prefix.
2. **Wallet and session data never outlives the tab.** Anything that
identifies the user or wallet goes in `sessionStorage` and is also wiped on
logout (`WalletProvider.disconnect` → `storage.clearSession`).
3. **`localStorage` is for UI preferences only.** Nothing identifying.
4. **No raw identifiers in key names.** Per-wallet keys use a one-way
fingerprint of the address (`fingerprint()` in `storageKeys.ts`), never the
address itself.
5. **New key?** Add it to `STORAGE_KEYS` and to the table below.

## Inventory

| Key | Area | Contents | Cleared |
| --- | --- | --- | --- |
| `ps_auth_token` | session | JWT from the sign-in challenge | tab close, logout, 401 |
| `ps_wallet_id` | session | Selected wallet module id (e.g. `freighter`) | tab close, logout |
| `ps_wallet_address` | session | Connected Stellar public key, used to re-check the wallet on reload | tab close, logout |
| `ps_wallet_network` | session | Network passphrase the wallet last reported | tab close, logout |
| `ps_expiry_notified_<fingerprint>` | session | Policy ids already shown an expiry warning | tab close, logout |
| `ps_testnet_banner_dismissed` | session | `"1"` once the testnet banner is dismissed | tab close |
| `ps_theme` | local | `"dark"` or `"light"` | never (preference) |
| `ps_category_filter` | local | Last product category filter, e.g. `"crop"` | never (preference) |
| `@StellarWalletsKit/usedWalletsIds` | local | Written by `@creit.tech/stellar-wallets-kit`: wallet module ids used in this browser, for ordering its picker. Third-party, can't be renamed. | logout |

## Migration from older builds

`migrateLegacyStorage()` runs when `WalletProvider` mounts and is a no-op once
there's nothing left to migrate:

- `ps_wallet_id`, `ps_wallet_address` and `ps_wallet_network` used to be in
`localStorage`, so the raw address stayed in the browser indefinitely. They
are removed from there; if the tab still has a live session they are copied
into `sessionStorage` first, so the upgrade doesn't log anyone out.
- `parashield_testnet_banner_dismissed` is renamed to
`ps_testnet_banner_dismissed`.

## What this does and doesn't protect against

Browser storage is per-origin: only scripts running on the Parashield origin
can read it, and any such script can read every value whatever the key is
called. Consistent, non-identifying key names and short retention reduce what
is left on a shared device and what a casual look at DevTools reveals. They
don't defend against a malicious script on the page; the Content Security
Policy in `netlify.toml` is what limits that.
31 changes: 31 additions & 0 deletions src/__tests__/CategoryFilter.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,37 @@ describe('CategoryFilter', () => {
expect(html).toContain('custom');
});

describe('keyboard navigation', () => {
function KeyboardHarness() {
const [value, setValue] = useState<'all' | 'crop' | 'flight' | 'disaster' | 'health' | 'defi'>('all');
return <CategoryFilter value={value} onChange={setValue} />;
}

it('moves selection and focus with ArrowDown/ArrowUp as well as Left/Right, wrapping', () => {
render(<KeyboardHarness />);
const all = screen.getByRole('tab', { name: /all policies/i });

fireEvent.keyDown(all, { key: 'ArrowDown' });
const crop = screen.getByRole('tab', { name: /crop/i });
expect(crop).toHaveAttribute('aria-selected', 'true');
expect(crop).toHaveFocus();

fireEvent.keyDown(crop, { key: 'ArrowUp' });
expect(all).toHaveFocus();
fireEvent.keyDown(all, { key: 'ArrowUp' });
expect(screen.getByRole('tab', { name: /defi/i })).toHaveFocus();
});

it('jumps to the ends with Home/End', () => {
render(<KeyboardHarness />);
fireEvent.keyDown(screen.getByRole('tab', { name: /all policies/i }), { key: 'End' });
const defi = screen.getByRole('tab', { name: /defi/i });
expect(defi).toHaveFocus();
fireEvent.keyDown(defi, { key: 'Home' });
expect(screen.getByRole('tab', { name: /all policies/i })).toHaveFocus();
});
});

describe('re-rendering (#590)', () => {
beforeEach(() => labelReads.clear());

Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/ClaimsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,42 @@ describe('ClaimsPage export dropdown (#546)', () => {

expect(screen.queryByRole('menu')).toBeNull();
});

it('opens from the keyboard and moves focus with arrow keys, Home and End', () => {
render(<ClaimsPage />);
const trigger = screen.getByRole('button', { name: /Export/ });

fireEvent.keyDown(trigger, { key: 'ArrowDown' });
const [csv, json] = screen.getAllByRole('menuitem');
expect(csv).toHaveFocus();

const menu = screen.getByRole('menu');
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(json).toHaveFocus();
fireEvent.keyDown(menu, { key: 'ArrowDown' }); // wraps
expect(csv).toHaveFocus();
fireEvent.keyDown(menu, { key: 'End' });
expect(json).toHaveFocus();
fireEvent.keyDown(menu, { key: 'Home' });
expect(csv).toHaveFocus();
fireEvent.keyDown(menu, { key: 'ArrowUp' }); // wraps
expect(json).toHaveFocus();
});

it('ArrowUp on the trigger opens on the last item', () => {
render(<ClaimsPage />);
fireEvent.keyDown(screen.getByRole('button', { name: /Export/ }), { key: 'ArrowUp' });
expect(screen.getByRole('menuitem', { name: /JSON/ })).toHaveFocus();
});

it('Escape closes the menu and returns focus to the trigger', () => {
render(<ClaimsPage />);
const trigger = screen.getByRole('button', { name: /Export/ });
fireEvent.keyDown(trigger, { key: 'ArrowDown' });

fireEvent.keyDown(document.activeElement!, { key: 'Escape' });

expect(screen.queryByRole('menu')).toBeNull();
expect(trigger).toHaveFocus();
});
});
108 changes: 108 additions & 0 deletions src/__tests__/Listbox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { describe, it, expect, vi } from 'vitest';
import { useState } from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { Listbox } from '../components/Listbox';

const OPTIONS = [
{ value: 'newest', label: 'Newest First' },
{ value: 'oldest', label: 'Oldest First' },
{ value: 'amount-high', label: 'Highest Amount' },
{ value: 'expiry', label: 'Expiring Soon' },
] as const;
type Value = (typeof OPTIONS)[number]['value'];

function Harness({ onChange = vi.fn() }: { onChange?: (v: Value) => void }) {
const [value, setValue] = useState<Value>('newest');
return (
<Listbox
value={value}
options={OPTIONS}
onChange={(v) => { setValue(v); onChange(v); }}
label="Sort policies"
/>
);
}

function setup() {
const onChange = vi.fn();
render(<Harness onChange={onChange} />);
const combobox = screen.getByRole('combobox', { name: 'Sort policies' });
const key = (k: string) => fireEvent.keyDown(combobox, { key: k });
const active = () => {
const id = combobox.getAttribute('aria-activedescendant');
return id ? document.getElementById(id)?.textContent : null;
};
return { combobox, key, active, onChange };
}

describe('Listbox keyboard pattern', () => {
it('opens with ArrowDown on the selected option and exposes listbox semantics', () => {
const { combobox, key, active } = setup();
expect(combobox).toHaveAttribute('aria-expanded', 'false');
expect(combobox).toHaveAttribute('aria-haspopup', 'listbox');

key('ArrowDown');
expect(combobox).toHaveAttribute('aria-expanded', 'true');
expect(screen.getByRole('listbox')).toBeVisible();
expect(active()).toBe('Newest First');
expect(screen.getByRole('option', { name: 'Newest First' })).toHaveAttribute('aria-selected', 'true');
});

it('moves with ArrowUp/ArrowDown and jumps with Home/End', () => {
const { key, active } = setup();
key('ArrowDown');
key('ArrowDown');
expect(active()).toBe('Oldest First');
key('End');
expect(active()).toBe('Expiring Soon');
key('ArrowDown'); // clamps at the end
expect(active()).toBe('Expiring Soon');
key('ArrowUp');
expect(active()).toBe('Highest Amount');
key('Home');
expect(active()).toBe('Newest First');
});

it('opens on the first / last option with Home / End while closed', () => {
const { key, active } = setup();
key('End');
expect(active()).toBe('Expiring Soon');
key('Escape');
key('Home');
expect(active()).toBe('Newest First');
});

it('selects with Enter and closes', () => {
const { combobox, key, onChange } = setup();
key('ArrowDown');
key('ArrowDown');
key('Enter');
expect(onChange).toHaveBeenCalledWith('oldest');
expect(combobox).toHaveAttribute('aria-expanded', 'false');
expect(combobox).toHaveTextContent('Oldest First');
});

it('Escape closes without changing the value', () => {
const { combobox, key, onChange } = setup();
key('ArrowDown');
key('End');
key('Escape');
expect(combobox).toHaveAttribute('aria-expanded', 'false');
expect(onChange).not.toHaveBeenCalled();
expect(combobox).toHaveTextContent('Newest First');
});

it('jumps to an option by typing its first letter', () => {
const { key, active } = setup();
key('ArrowDown');
key('h');
expect(active()).toBe('Highest Amount');
});

it('still works with the mouse', () => {
const { combobox, onChange } = setup();
fireEvent.click(combobox);
fireEvent.click(screen.getByRole('option', { name: 'Expiring Soon' }));
expect(onChange).toHaveBeenCalledWith('expiry');
});
});
2 changes: 1 addition & 1 deletion src/__tests__/WalletContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ describe('WalletProvider logout cleanup (#687)', () => {
act(() => wallet.ctx.disconnect());

expect(storage.clearSession).toHaveBeenCalledWith(
['ps_auth_token', 'ps_wallet_id', 'ps_wallet_address', 'ps_wallet_network'],
['ps_auth_token', 'ps_wallet_id', 'ps_wallet_address', 'ps_wallet_network', '@StellarWalletsKit/usedWalletsIds'],
['ps_expiry_notified_'],
);
});
Expand Down
120 changes: 120 additions & 0 deletions src/__tests__/doubleSubmit.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { BuyPolicyModal } from '../components/BuyPolicyModal';
import { DepositModal } from '../components/DepositModal';
import { invokeBuyPolicy, buildDepositTx, submitSignedTransaction } from '@/lib/contract';
import { SUBMIT_COOLDOWN_MS } from '@/hooks/useSubmitGuard';
import type { PoolStats, Product } from '../types';

vi.mock('@/hooks/useWallet', () => ({
useWallet: vi.fn(() => ({ address: 'GABC123', connect: vi.fn(), connecting: false, error: null })),
}));
vi.mock('@/context/ToastContext', () => ({ useToast: vi.fn(() => ({ show: vi.fn() })) }));
vi.mock('@/lib/stellar', () => ({ signTransaction: vi.fn(async () => 'signed-xdr') }));
vi.mock('@/lib/api', () => ({
buyPolicy: vi.fn(async () => ({})),
fetchPoolShares: vi.fn(async () => ({ shareSupply: '10000000', totalLiquidity: '100000000', paused: false })),
}));

// A request that stays in flight until the test resolves it.
let resolveRequest: () => void = () => {};
vi.mock('@/lib/contract', () => ({
invokeBuyPolicy: vi.fn(
() => new Promise((resolve) => { resolveRequest = () => resolve({ txHash: 'abc123def456', signedXdr: 'x' }); }),
),
buildDepositTx: vi.fn(async () => 'unsigned-xdr'),
submitSignedTransaction: vi.fn(
() => new Promise<void>((resolve) => { resolveRequest = () => resolve(); }),
),
withStateRestore: vi.fn((fn: () => Promise<unknown>) => fn()),
}));

const product: Product = {
id: 'product-1', name: 'DeFi Cover', category: 'defi', triggerType: 'Threshold', threshold: '30',
comparison: 'LessThan', coverageMin: '1000000', coverageMax: '100000000', premiumRate: 500,
maxDuration: 30, status: 'Active',
} as Product;

const pool: PoolStats = {
poolId: 'pool-1', category: 'crop', totalLiquidity: '100000000', activePolicies: 3, utilizationRate: 0.4, apy: 0.12,
};

/** Let the user "think" between deliberate clicks so the cooldown has passed. */
function pause() {
act(() => { vi.advanceTimersByTime(SUBMIT_COOLDOWN_MS + 1); });
}

async function flush() {
await act(async () => { await Promise.resolve(); });
}

describe('double-click protection', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['Date', 'setTimeout', 'clearTimeout'] });
vi.clearAllMocks();
});
afterEach(() => vi.useRealTimers());

it('BuyPolicyModal: double-click on "Confirm details" does not also sign', async () => {
render(<BuyPolicyModal product={product} onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText(/–/), { target: { value: '5' } });

fireEvent.click(screen.getByRole('button', { name: 'Next' }));
await flush();
pause();

const confirm = screen.getByRole('button', { name: 'Confirm details' });
fireEvent.click(confirm);
fireEvent.click(confirm);
await flush();

expect(screen.getByRole('button', { name: /sign & confirm/i })).toBeInTheDocument();
expect(invokeBuyPolicy).not.toHaveBeenCalled();
});

it('BuyPolicyModal: rapid clicks on "Sign & Confirm" fire one request and show a loading state', async () => {
render(<BuyPolicyModal product={product} onClose={vi.fn()} />);
fireEvent.change(screen.getByPlaceholderText(/–/), { target: { value: '5' } });
for (const name of ['Next', 'Confirm details']) {
fireEvent.click(screen.getByRole('button', { name }));
await flush();
pause();
}

const sign = screen.getByRole('button', { name: /sign & confirm/i });
fireEvent.click(sign);
fireEvent.click(sign);
await flush();
// Even after the cooldown, a click while the request is in flight is dropped.
pause();
fireEvent.click(sign);
await flush();

expect(invokeBuyPolicy).toHaveBeenCalledTimes(1);
const busyButton = screen.getByRole('button', { name: /submitting/i });
expect(busyButton).toBeDisabled();
expect(busyButton).toHaveAttribute('aria-busy', 'true');

await act(async () => { resolveRequest(); });
});

it('DepositModal: double-click submits one deposit and disables the button in flight', async () => {
render(<DepositModal pool={pool} onClose={vi.fn()} />);
await flush();
fireEvent.change(screen.getByLabelText(/amount/i), { target: { value: '10' } });

const submit = screen.getByRole('button', { name: /confirm deposit/i });
fireEvent.click(submit);
fireEvent.click(submit);
await flush();
await flush();

expect(buildDepositTx).toHaveBeenCalledTimes(1);
expect(submitSignedTransaction).toHaveBeenCalledTimes(1);
const busyButton = screen.getByRole('button', { name: /confirming on chain/i });
expect(busyButton).toBeDisabled();
expect(busyButton).toHaveAttribute('aria-busy', 'true');

await act(async () => { resolveRequest(); });
});
});
Loading