diff --git a/__mocks__/viem.js b/__mocks__/viem.js index c79247a1..d55bba2b 100644 --- a/__mocks__/viem.js +++ b/__mocks__/viem.js @@ -58,4 +58,5 @@ module.exports = { formatEther: jest.fn((wei) => Number(wei) / 1e18), parseEther: jest.fn((eth) => BigInt(Math.floor(Number(eth) * 1e18))), parseUnits: jest.fn((val, decimals) => BigInt(Number(val) * Math.pow(10, decimals))), + defineChain: jest.fn((chain) => chain), }; diff --git a/jest.setup.js b/jest.setup.js index 3ea5e2ee..49a40b33 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -138,15 +138,13 @@ const sessionStorageMock = { } global.sessionStorage = sessionStorageMock -// Polyfill crypto.randomUUID for jsdom (Node.js <19) +// Polyfill crypto.randomUUID for jsdom (Node.js <19 / jsdom without randomUUID) if (typeof globalThis.crypto !== 'undefined' && !globalThis.crypto.randomUUID) { - let counter = BigInt(0); globalThis.crypto.randomUUID = function randomUUID() { - counter++; - const hex = (counter + BigInt(Date.now()) * BigInt(100000)).toString(16); - return hex.slice(0, 36).replace( - /^(.{8})(.{4})(.{4})(.{4})(.{12})$/, - '$1-$2-4$3-a$4-$5' - ); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0; + const v = c === 'x' ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); } } diff --git a/package-lock.json b/package-lock.json index 73944842..3e80170d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@tanstack/react-query-devtools": "^5.100.2", "@tanstack/react-virtual": "^3.13.24", "@testing-library/dom": "^10.4.1", + "@upstash/redis": "^1.38.0", "@wagmi/connectors": "^7.1.2", "@wagmi/core": "3.4.0", "@walletconnect/web3-provider": "^1.8.0", @@ -7718,6 +7719,15 @@ "win32" ] }, + "node_modules/@upstash/redis": { + "version": "1.38.3", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.3.tgz", + "integrity": "sha512-vtS0BonQHU6kDSWvHTISh+LOuLIEk+jeMXebv20CDJ/aHGOG085SGa8OpI+I+Ow8WgPFhqH23XG8kQ2LXXRnag==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vitest/browser": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.5.tgz", @@ -22544,6 +22554,12 @@ "node": ">=0.10.0" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/src/components/kyc/KycVerificationCenter.tsx b/src/components/kyc/KycVerificationCenter.tsx index eb367d43..ea1fc9a7 100644 --- a/src/components/kyc/KycVerificationCenter.tsx +++ b/src/components/kyc/KycVerificationCenter.tsx @@ -126,6 +126,7 @@ export function KycVerificationCenter() {

{ + beforeEach(() => { + useKycStore.getState().resetKyc(); + }); + + it('shows an empty state when there is no compliance activity', () => { + render(); + + expect( + screen.getByText('No compliance activity yet.') + ).toBeInTheDocument(); + }); + + it('renders a human-readable label for each audit entry', () => { + useKycStore.setState({ + auditLog: [ + { + id: 'log-1', + timestamp: '2026-08-01T10:00:00.000Z', + event: 'document_uploaded', + details: { name: 'passport.pdf' }, + }, + { + id: 'log-2', + timestamp: '2026-08-01T10:05:00.000Z', + event: 'verification_approved', + details: { provider: 'TrustLayer Mock' }, + }, + ], + }); + + render(); + + expect(screen.getByText('Document uploaded')).toBeInTheDocument(); + expect(screen.getByText('Verification approved')).toBeInTheDocument(); + expect(screen.queryByText('No compliance activity yet.')).not.toBeInTheDocument(); + }); + + it('renders the event details for each entry', () => { + useKycStore.setState({ + auditLog: [ + { + id: 'log-1', + timestamp: '2026-08-01T10:00:00.000Z', + event: 'transaction_blocked', + details: { valueEth: 25, allowed: false }, + }, + ], + }); + + render(); + + expect(screen.getByText('Transaction blocked')).toBeInTheDocument(); + expect(screen.getByText('valueEth: 25')).toBeInTheDocument(); + expect(screen.getByText('allowed: false')).toBeInTheDocument(); + }); + + it('covers all known event types with labels', () => { + const events: ComplianceLogEntry['event'][] = [ + 'threshold_updated', + 'document_uploaded', + 'liveness_started', + 'liveness_passed', + 'liveness_failed', + 'verification_submitted', + 'verification_approved', + 'verification_rejected', + 'transaction_screened', + 'transaction_blocked', + ]; + + useKycStore.setState({ + auditLog: events.map((event, index) => ({ + id: `log-${index}`, + timestamp: `2026-08-01T10:0${index}:00.000Z`, + event, + details: {}, + })), + }); + + render(); + + expect(screen.getByText('Threshold updated')).toBeInTheDocument(); + expect(screen.getByText('Document uploaded')).toBeInTheDocument(); + expect(screen.getByText('Liveness started')).toBeInTheDocument(); + expect(screen.getByText('Liveness passed')).toBeInTheDocument(); + expect(screen.getByText('Liveness failed')).toBeInTheDocument(); + expect(screen.getByText('Verification submitted')).toBeInTheDocument(); + expect(screen.getByText('Verification approved')).toBeInTheDocument(); + expect(screen.getByText('Verification rejected')).toBeInTheDocument(); + expect(screen.getByText('Transaction screened')).toBeInTheDocument(); + expect(screen.getByText('Transaction blocked')).toBeInTheDocument(); + }); +}); diff --git a/src/components/kyc/__tests__/KycStatusBadge.test.tsx b/src/components/kyc/__tests__/KycStatusBadge.test.tsx new file mode 100644 index 00000000..05f68e9f --- /dev/null +++ b/src/components/kyc/__tests__/KycStatusBadge.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { KycStatusBadge } from '../KycStatusBadge'; + +describe('KycStatusBadge', () => { + it('renders the KYC required label for unverified status', () => { + render(); + + expect(screen.getByText('KYC required')).toBeInTheDocument(); + }); + + it('renders the KYC pending label for pending status', () => { + render(); + + expect(screen.getByText('KYC pending')).toBeInTheDocument(); + }); + + it('renders the KYC verified label for verified status', () => { + render(); + + expect(screen.getByText('KYC verified')).toBeInTheDocument(); + }); + + it('renders the review needed label for rejected status', () => { + render(); + + expect(screen.getByText('KYC review needed')).toBeInTheDocument(); + }); + + it('annotates the threshold in the title attribute', () => { + render(); + + expect( + screen.getByTitle('High-value transactions above 25 ETH require KYC review') + ).toBeInTheDocument(); + }); + + it('uses a different title when already verified', () => { + render(); + + expect( + screen.getByTitle('High-value transactions above 25 ETH are allowed') + ).toBeInTheDocument(); + }); + + it('renders the compact variant without the full label', () => { + const { rerender } = render( + + ); + + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.queryByText('KYC verified')).not.toBeInTheDocument(); + + rerender( + + ); + + expect(screen.getByText('KYC')).toBeInTheDocument(); + expect(screen.queryByText('KYC required')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/kyc/__tests__/KycVerificationCenter.test.tsx b/src/components/kyc/__tests__/KycVerificationCenter.test.tsx new file mode 100644 index 00000000..cb1bfd70 --- /dev/null +++ b/src/components/kyc/__tests__/KycVerificationCenter.test.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { KycVerificationCenter } from '../KycVerificationCenter'; +import { useKycStore } from '@/store/kycStore'; +import type { KycDocument } from '@/types/kyc'; + +const makeFile = (name: string, type: string): File => + new File(['content'], name, { type }); + +describe('KycVerificationCenter', () => { + beforeEach(() => { + useKycStore.getState().resetKyc(); + }); + + it('renders the provider and initial unverified status', () => { + render(); + + expect( + screen.getByText(/provider: trustlayer mock/i) + ).toBeInTheDocument(); + expect(screen.getByText('unverified')).toBeInTheDocument(); + expect(screen.getByText(/liveness: not_started/i)).toBeInTheDocument(); + }); + + it('saves a new threshold through the store', () => { + render(); + + const input = screen.getByLabelText('KYC threshold (ETH)'); + fireEvent.change(input, { target: { value: '25' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + expect(useKycStore.getState().profile.thresholdEth).toBe(25); + expect( + useKycStore.getState().auditLog[0].event + ).toBe('threshold_updated'); + }); + + it('records uploaded documents in the store', async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByTestId('kyc-file-input'), { + target: { files: [makeFile('passport.pdf', 'application/pdf')] }, + }); + }); + + const profile = useKycStore.getState().profile; + expect(profile.documents).toHaveLength(1); + expect(profile.documents[0].name).toBe('passport.pdf'); + expect(profile.status).toBe('pending'); + expect( + screen.getByText('1 document(s) uploaded') + ).toBeInTheDocument(); + }); + + it('runs the liveness check and advances the step after it passes', async () => { + jest.useFakeTimers(); + try { + render(); + + fireEvent.click( + screen.getByRole('button', { name: /run liveness check/i }) + ); + + expect(useKycStore.getState().profile.livenessStatus).toBe('pending'); + + await act(async () => { + jest.advanceTimersByTime(1200); + }); + + expect(useKycStore.getState().profile.livenessStatus).toBe('passed'); + expect( + screen.getByText('Liveness status: passed') + ).toBeInTheDocument(); + } finally { + jest.useRealTimers(); + } + }); + + it('gates submission until documents and liveness are complete', async () => { + render(); + + fireEvent.click( + screen.getByRole('button', { name: /submit verification/i }) + ); + + // Without documents or a passed liveness check the profile is rejected. + expect(useKycStore.getState().profile.status).toBe('rejected'); + expect( + useKycStore.getState().profile.rejectedReason + ).toContain('Upload documents and pass liveness'); + }); + + it('approves verification once all steps are complete', async () => { + const documents: KycDocument[] = [ + { + id: 'doc-1', + name: 'passport.pdf', + type: 'application/pdf', + size: 1024, + uploadedAt: '2026-08-01T10:00:00.000Z', + }, + ]; + useKycStore.getState().addDocuments(documents); + useKycStore.getState().completeLivenessCheck(true); + + render(); + + fireEvent.click( + screen.getByRole('button', { name: /submit verification/i }) + ); + + await waitFor(() => { + expect(useKycStore.getState().profile.status).toBe('verified'); + }); + + expect( + screen.getByRole('button', { name: /view compliance log/i }) + ).toBeInTheDocument(); + expect( + useKycStore.getState().auditLog.some( + (entry) => entry.event === 'verification_approved' + ) + ).toBe(true); + }); + + it('shows a rejected state after a failed liveness attempt', () => { + useKycStore.getState().startLivenessCheck(); + useKycStore.getState().completeLivenessCheck(false); + + render(); + + expect(useKycStore.getState().profile.status).toBe('rejected'); + expect( + screen.getByText('Liveness status: failed') + ).toBeInTheDocument(); + }); + + it('resets the flow back to defaults', () => { + useKycStore.getState().addDocuments([ + { + id: 'doc-1', + name: 'passport.pdf', + type: 'application/pdf', + size: 1024, + uploadedAt: '2026-08-01T10:00:00.000Z', + }, + ]); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Reset' })); + + const profile = useKycStore.getState().profile; + expect(profile.documents).toHaveLength(0); + expect(profile.status).toBe('unverified'); + }); + + it('updates the completion progress as steps are completed', async () => { + const { container } = render(); + + const indicator = container.querySelector('[data-slot="progress-indicator"]') as HTMLElement; + expect(indicator).toHaveStyle('transform: translateX(-100%)'); + + await act(async () => { + fireEvent.change(screen.getByTestId('kyc-file-input'), { + target: { files: [makeFile('passport.pdf', 'application/pdf')] }, + }); + }); + + // One of three steps complete → 33% filled. + expect(indicator.style.transform).toMatch(/translateX\(-66\.\d+%\)/); + }); +}); diff --git a/src/components/property/__tests__/DocumentSection.test.tsx b/src/components/property/__tests__/DocumentSection.test.tsx new file mode 100644 index 00000000..52546f0d --- /dev/null +++ b/src/components/property/__tests__/DocumentSection.test.tsx @@ -0,0 +1,119 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import DocumentSection from '../DocumentSection'; +import type { PropertyDocument } from '../PdfViewerModal'; + +// PdfViewerModal pulls in react-pdf, which is not installed, so we stub the +// modal surface and assert on the props it receives. +jest.mock('../PdfViewerModal', () => ({ + __esModule: true, + default: ({ + doc, + onClose, + }: { + doc: PropertyDocument; + onClose: () => void; + }) => ( +
+ Viewing: {doc.name} + +
+ ), +})); + +const documents: PropertyDocument[] = [ + { + id: 'doc-1', + name: 'Title Deed.pdf', + url: '/docs/title-deed.pdf', + category: 'Legal', + verified: true, + }, + { + id: 'doc-2', + name: 'Inspection Report.pdf', + url: '/docs/inspection.pdf', + category: 'Inspection', + verified: false, + }, +]; + +describe('DocumentSection', () => { + it('renders a heading per category that has documents', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Legal' })).toBeInTheDocument(); + expect( + screen.getByRole('heading', { name: 'Inspection' }) + ).toBeInTheDocument(); + }); + + it('does not render headings for categories without documents', () => { + render(); + + expect( + screen.queryByRole('heading', { name: 'Financial' }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: 'Photos' }) + ).not.toBeInTheDocument(); + }); + + it('renders every document name under its category', () => { + render(); + + expect(screen.getByText('Title Deed.pdf')).toBeInTheDocument(); + expect(screen.getByText('Inspection Report.pdf')).toBeInTheDocument(); + }); + + it('shows the verification state for each document', () => { + render(); + + expect(screen.getByText('Verified')).toBeInTheDocument(); + expect(screen.getByText('Pending Verification')).toBeInTheDocument(); + }); + + it('renders a download link pointing at the document url', () => { + render(); + + const downloads = screen.getAllByRole('link', { name: 'Download' }); + expect(downloads).toHaveLength(2); + expect(downloads[0]).toHaveAttribute('href', '/docs/title-deed.pdf'); + expect(downloads[0]).toHaveAttribute('download'); + expect(downloads[1]).toHaveAttribute('href', '/docs/inspection.pdf'); + }); + + it('opens the pdf viewer modal with the selected document', () => { + render(); + + expect( + screen.queryByTestId('pdf-viewer-modal') + ).not.toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole('button', { name: 'View' })[0]); + + expect(screen.getByTestId('pdf-viewer-modal')).toBeInTheDocument(); + expect( + screen.getByText('Viewing: Title Deed.pdf') + ).toBeInTheDocument(); + }); + + it('closes the modal when the close action is triggered', () => { + render(); + + fireEvent.click(screen.getAllByRole('button', { name: 'View' })[0]); + expect(screen.getByTestId('pdf-viewer-modal')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Close modal' })); + expect( + screen.queryByTestId('pdf-viewer-modal') + ).not.toBeInTheDocument(); + }); + + it('renders nothing when there are no documents', () => { + const { container } = render(); + + expect(container.querySelector('section')).not.toBeInTheDocument(); + expect(container.textContent).toBe(''); + }); +}); diff --git a/src/components/property/__tests__/ShareButton.test.tsx b/src/components/property/__tests__/ShareButton.test.tsx new file mode 100644 index 00000000..7673c15c --- /dev/null +++ b/src/components/property/__tests__/ShareButton.test.tsx @@ -0,0 +1,217 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { ShareButton } from '../ShareButton'; +import { toast } from 'sonner'; + +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, +})); + +const property = { + id: 'prop-1', + name: 'Lakeside Villa', + location: { city: 'Austin', state: 'TX' }, + price: { total: 2.5, perToken: 0.1 }, + images: ['/images/lakeside.jpg'], + metrics: { roi: 12 }, +}; + +const shareUrl = `${window.location.origin}/properties/prop-1`; + +describe('ShareButton', () => { + let openSpy: jest.SpyInstance; + + beforeEach(() => { + jest.clearAllMocks(); + openSpy = jest.spyOn(window, 'open').mockImplementation(() => null); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + }); + + afterEach(() => { + openSpy.mockRestore(); + // Remove native share support so it does not leak between tests. + delete (navigator as { share?: unknown }).share; + }); + + it('renders the share trigger button', () => { + render(); + + expect( + screen.getByRole('button', { name: /share/i }) + ).toBeInTheDocument(); + }); + + it('opens the share dialog when the trigger is clicked', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + + expect( + await screen.findByRole('heading', { name: 'Share Property' }) + ).toBeInTheDocument(); + }); + + it('shows a property preview inside the dialog', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + expect(screen.getByText('Lakeside Villa')).toBeInTheDocument(); + expect(screen.getByText('Austin, TX')).toBeInTheDocument(); + expect(screen.getByText('2.5 ETH')).toBeInTheDocument(); + expect(screen.getByText('12% ROI')).toBeInTheDocument(); + }); + + it('opens the twitter share intent when twitter is clicked', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /twitter/i })); + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('twitter.com/intent/tweet'), + '_blank', + expect.any(String) + ); + expect(toast.success).toHaveBeenCalledWith( + 'Opening Twitter share dialog...' + ); + }); + + it('opens the linkedin share intent when linkedin is clicked', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /linkedin/i })); + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('linkedin.com/sharing/share-offsite'), + '_blank', + expect.any(String) + ); + }); + + it('opens a mailto link when email is clicked', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /email/i })); + + expect(openSpy).toHaveBeenCalledWith( + expect.stringContaining('mailto:') + ); + }); + + it('copies the property link to the clipboard', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /copy link/i })); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(shareUrl); + expect(await screen.findByText('Copied!')).toBeInTheDocument(); + expect(toast.success).toHaveBeenCalledWith('Link copied to clipboard!'); + }); + + it('falls back to copying the link when native share is unsupported', async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + expect( + screen.queryByRole('button', { name: /native share/i }) + ).not.toBeInTheDocument(); + }); + + it('shows and invokes the native share option when supported', async () => { + const shareMock = jest.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, 'share', { + configurable: true, + value: shareMock, + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + const nativeButton = screen.getByRole('button', { name: /native share/i }); + expect(nativeButton).toBeInTheDocument(); + + fireEvent.click(nativeButton); + + await waitFor(() => { + expect(shareMock).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'Lakeside Villa', + url: shareUrl, + }) + ); + }); + expect(toast.success).toHaveBeenCalledWith( + 'Property shared successfully!' + ); + }); + + it('silently ignores a cancelled native share (AbortError)', async () => { + const shareMock = jest.fn().mockRejectedValue( + Object.assign(new Error('aborted'), { name: 'AbortError' }) + ); + Object.defineProperty(navigator, 'share', { + configurable: true, + value: shareMock, + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /native share/i })); + + await waitFor(() => { + expect(shareMock).toHaveBeenCalled(); + }); + expect(navigator.clipboard.writeText).not.toHaveBeenCalled(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('shows an error toast when the native share fails', async () => { + const shareMock = jest + .fn() + .mockRejectedValue(new Error('share unavailable')); + Object.defineProperty(navigator, 'share', { + configurable: true, + value: shareMock, + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /share/i })); + await screen.findByRole('heading', { name: 'Share Property' }); + + fireEvent.click(screen.getByRole('button', { name: /native share/i })); + + await waitFor(() => { + expect(toast.error).toHaveBeenCalledWith('Failed to share property'); + }); + }); +}); diff --git a/src/components/security/__tests__/TransactionSecuritySettings.test.tsx b/src/components/security/__tests__/TransactionSecuritySettings.test.tsx new file mode 100644 index 00000000..c177c1c6 --- /dev/null +++ b/src/components/security/__tests__/TransactionSecuritySettings.test.tsx @@ -0,0 +1,340 @@ +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { TransactionSecuritySettings } from '../TransactionSecuritySettings'; +import { useTransactionSecurityStore } from '@/store/transactionSecurityStore'; +import { toast } from 'sonner'; + +jest.mock('sonner', () => ({ + toast: { + success: jest.fn(), + error: jest.fn(), + warning: jest.fn(), + }, +})); + +jest.mock('@/store/transactionSecurityStore', () => ({ + useTransactionSecurityStore: jest.fn(), +})); + +jest.mock('qrcode', () => ({ + toDataURL: jest.fn().mockResolvedValue('data:image/png;base64,QUJD'), +})); + +jest.mock('@/utils/security/transactionSecurity', () => ({ + buildOtpAuthUri: jest.fn( + ({ issuer, accountName }: { issuer: string; accountName: string }) => + `otpauth://totp/${issuer}:${accountName}?secret=SECRET` + ), + formatTrustedDeviceExpiry: jest.fn((timestamp: number) => + new Date(timestamp).toLocaleDateString() + ), + getSecurityDeviceId: jest.fn(() => 'device-test-1'), + getSecurityDeviceLabel: jest.fn(() => 'Test Browser'), +})); + +jest.mock('@/utils/security/totp', () => ({ + normalizeTotpCode: jest.fn((code: string) => code.trim()), +})); + +const defaultSettings = { + thresholdEth: 2, + twoFactorRequired: true, + totpEnabled: true, + hardwareWalletEnabled: true, + trustedDeviceBypass: true, + trustedDeviceDurationDays: 30, + totpIssuer: 'PropChain', + totpAccountLabel: 'Primary wallet', + totpSecret: null, +}; + +const storeState = { + settings: { ...defaultSettings }, + trustedDevices: [] as { id: string; label: string; trustUntil: number }[], + lastVerifiedAt: null as number | null, + lastVerificationMethod: null as string | null, + updateSettings: jest.fn(), + enrollTotp: jest.fn(() => ({ + secret: 'MOCK-SECRET', + otpauthUri: 'otpauth://totp/PropChain:Primary%20wallet?secret=MOCK-SECRET', + })), + verifyTotpCode: jest.fn().mockResolvedValue(true), + trustDevice: jest.fn(() => ({ + id: 'device-test-1', + label: 'Test Browser', + createdAt: 1, + lastUsedAt: 1, + trustUntil: Date.now() + 30 * 24 * 60 * 60 * 1000, + })), + revokeTrustedDevice: jest.fn(), + clearTrustedDevices: jest.fn(), + setLastVerification: jest.fn(), + resetSecurity: jest.fn(), + getActiveTrustedDevice: jest.fn(() => null), +}; + +const mockedStore = useTransactionSecurityStore as jest.Mock; + +describe('TransactionSecuritySettings', () => { + beforeEach(() => { + jest.clearAllMocks(); + storeState.settings = { ...defaultSettings }; + storeState.trustedDevices = []; + storeState.lastVerifiedAt = null; + storeState.lastVerificationMethod = null; + storeState.getActiveTrustedDevice.mockReturnValue(null); + mockedStore.mockReturnValue(storeState); + }); + + it('renders the security section with the store settings reflected', () => { + render(); + + expect(screen.getByText('Transaction Security')).toBeInTheDocument(); + + const switches = screen.getAllByRole('switch'); + expect(switches).toHaveLength(4); + expect(switches[0]).toHaveAttribute('data-state', 'checked'); + expect(switches[1]).toHaveAttribute('data-state', 'checked'); + expect(switches[2]).toHaveAttribute('data-state', 'checked'); + expect(switches[3]).toHaveAttribute('data-state', 'checked'); + }); + + it('persists the step-up verification toggle via the store', () => { + render(); + + fireEvent.click(screen.getAllByRole('switch')[0]); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + twoFactorRequired: false, + }); + }); + + it('persists the totp toggle via the store', () => { + render(); + + fireEvent.click(screen.getAllByRole('switch')[1]); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + totpEnabled: false, + }); + }); + + it('persists the hardware wallet toggle via the store', () => { + render(); + + fireEvent.click(screen.getAllByRole('switch')[2]); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + hardwareWalletEnabled: false, + }); + }); + + it('persists the trusted device bypass toggle via the store', () => { + render(); + + fireEvent.click(screen.getAllByRole('switch')[3]); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + trustedDeviceBypass: false, + }); + }); + + it('reflects the persisted enabled state of each toggle on render', () => { + storeState.settings = { + ...defaultSettings, + twoFactorRequired: false, + totpEnabled: false, + hardwareWalletEnabled: false, + trustedDeviceBypass: false, + }; + + render(); + + const switches = screen.getAllByRole('switch'); + switches.forEach((sw) => + expect(sw).toHaveAttribute('data-state', 'unchecked') + ); + + // Current policy summary mirrors the saved settings. + expect(screen.getByText('2FA off')).toBeInTheDocument(); + expect(screen.queryByText('2FA on')).not.toBeInTheDocument(); + }); + + it('shows the current policy summary from persisted settings', () => { + render(); + + expect(screen.getByText('2FA on')).toBeInTheDocument(); + // Threshold appears both in the header badge and the policy summary. + expect(screen.getAllByText('2.00 ETH').length).toBeGreaterThanOrEqual(2); + expect(screen.getAllByText('Enabled')).toHaveLength(3); // TOTP, hardware wallet, trusted bypass + }); + + it('saves a new high-value threshold', () => { + render(); + + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '5' } }); + fireEvent.click(screen.getByRole('button', { name: /save threshold/i })); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + thresholdEth: 5, + }); + expect(toast.success).toHaveBeenCalledWith('Security threshold saved'); + }); + + it('clamps out-of-range thresholds to the allowed bounds', () => { + render(); + + const input = screen.getByRole('spinbutton'); + fireEvent.change(input, { target: { value: '99' } }); + fireEvent.click(screen.getByRole('button', { name: /save threshold/i })); + + expect(storeState.updateSettings).toHaveBeenCalledWith({ + thresholdEth: 25, + }); + }); + + it('keeps the trust-this-browser button disabled until a verification exists', () => { + storeState.lastVerifiedAt = null; + render(); + + const trustButton = screen.getByRole('button', { + name: /trust this browser/i, + }); + expect(trustButton).toBeDisabled(); + + fireEvent.click(trustButton); + expect(storeState.trustDevice).not.toHaveBeenCalled(); + }); + + it('trusts the current browser once a verification exists', () => { + storeState.lastVerifiedAt = Date.now(); + storeState.lastVerificationMethod = 'totp'; + render(); + + fireEvent.click( + screen.getByRole('button', { name: /trust this browser/i }) + ); + + expect(storeState.trustDevice).toHaveBeenCalledWith( + 'device-test-1', + 'Test Browser' + ); + expect(toast.success).toHaveBeenCalledWith( + expect.stringContaining('Trusted') + ); + }); + + it('shows the last verification method banner', () => { + storeState.lastVerifiedAt = Date.now(); + storeState.lastVerificationMethod = 'totp'; + render(); + + expect( + screen.getByText(/last verified via totp/i) + ).toBeInTheDocument(); + }); + + it('lists trusted devices and revokes them', () => { + storeState.trustedDevices = [ + { + id: 'device-a', + label: 'Work laptop', + trustUntil: Date.now() + 1000, + }, + ]; + render(); + + expect(screen.getByText('Work laptop')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: '' })); + + expect(storeState.revokeTrustedDevice).toHaveBeenCalledWith('device-a'); + }); + + it('shows the active trusted device state', () => { + storeState.trustedDevices = [ + { + id: 'device-test-1', + label: 'Test Browser', + trustUntil: Date.now() + 30 * 24 * 60 * 60 * 1000, + }, + ]; + + render(); + + expect(screen.getByText('Trusted now')).toBeInTheDocument(); + // Appears in the active-device card and again in the devices list. + expect(screen.getAllByText('Test Browser').length).toBeGreaterThanOrEqual(2); + }); + + it('enrolls a totp secret and copies it to the clipboard', async () => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined), + }, + }); + + render(); + + fireEvent.click( + screen.getByRole('button', { name: /set up authenticator/i }) + ); + + expect(storeState.enrollTotp).toHaveBeenCalled(); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith('MOCK-SECRET'); + expect(toast.success).toHaveBeenCalledWith('Authenticator setup started'); + }); + + it('shows the qr code and secret after enrolling totp', async () => { + storeState.settings = { + ...defaultSettings, + totpSecret: 'MOCK-SECRET', + }; + + render(); + + expect(await screen.findByText('MOCK-SECRET')).toBeInTheDocument(); + expect( + await screen.findByAltText('Authenticator QR code') + ).toBeInTheDocument(); + }); + + it('disables the verify button until a 6-digit code is entered', () => { + storeState.settings = { + ...defaultSettings, + totpSecret: 'MOCK-SECRET', + }; + + render(); + + expect( + screen.getByRole('button', { name: /verify authenticator/i }) + ).toBeDisabled(); + }); + + it('copies the secret with the copy button', async () => { + storeState.settings = { + ...defaultSettings, + totpSecret: 'MOCK-SECRET', + }; + const writeText = jest.fn().mockResolvedValue(undefined); + Object.assign(navigator, { clipboard: { writeText } }); + + render(); + + await waitFor(() => expect(screen.getByText('MOCK-SECRET')).toBeInTheDocument()); + + const copyButtons = screen + .getAllByRole('button') + .filter((button) => button.querySelector('svg')); + fireEvent.click(copyButtons[copyButtons.length - 1]); + + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith('MOCK-SECRET'); + }); + expect(toast.success).toHaveBeenCalledWith( + 'Authenticator secret copied' + ); + }); +}); diff --git a/src/lib/__tests__/cacheManager.test.ts b/src/lib/__tests__/cacheManager.test.ts index 9b9318c7..1178d91f 100644 --- a/src/lib/__tests__/cacheManager.test.ts +++ b/src/lib/__tests__/cacheManager.test.ts @@ -59,6 +59,26 @@ function makeTrackableStorage() { }; } +/* -------------------------------------------------------------------------- */ +/* ethers mock */ +/* -------------------------------------------------------------------------- */ +/* ethers' sha256 hashes via node:crypto, which returns a Node Buffer. In the + * jsdom test environment Buffer is a different realm from the global + * Uint8Array, so ethers' internal instanceof check rejects the digest and + * sha256 always throws. Provide deterministic equivalents so the sync-queue + * dedupe logic can be exercised. */ + +jest.mock('ethers', () => { + const actual = jest.requireActual('ethers'); + const { createHash } = jest.requireActual('node:crypto'); + return { + ...actual, + toUtf8Bytes: (value: string) => Buffer.from(value, 'utf8'), + sha256: (data: Uint8Array) => + '0x' + createHash('sha256').update(Buffer.from(data)).digest('hex'), + }; +}); + /* -------------------------------------------------------------------------- */ /* propertyCache mock */ /* -------------------------------------------------------------------------- */ @@ -302,7 +322,9 @@ describe('cacheManager', () => { retries: 0, timestamp: expect.any(Number), }); - expect(queue[0].id).toMatch(/^\d+-[a-z0-9]+$/); + // Items are identified with a UUID (crypto.randomUUID), not the legacy + // `Date.now()-random` scheme. + expect(queue[0].id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); }); it('clears the queue', () => {