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
32 changes: 32 additions & 0 deletions jest.setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,38 @@ global.ResizeObserver = class {
disconnect() {}
}

// jsdom has no IntersectionObserver; embla-carousel's SlidesInView needs it on init.
global.IntersectionObserver = class {
root = null
rootMargin = ''
thresholds = []
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return []
}
} as unknown as typeof IntersectionObserver

// jsdom has no matchMedia; embla-carousel calls it for breakpoint options on init.
// Suites that assert on matchMedia behavior override this with their own stub.
// Guarded: a few suites run in the node environment, where window doesn't exist.
if (typeof window !== 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(),
removeListener: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
}),
})
}

// Add any global test setup here
global.console = {
...console,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -334,6 +334,7 @@ jest.mock('@/utils/currency', () => ({
}))

jest.mock('@/utils/format.utils', () => ({
...jest.requireActual('@/utils/format.utils'),
formatBankAccountDisplay: jest.fn((val: string) => val),
}))

Expand Down
7 changes: 7 additions & 0 deletions src/app/(mobile-ui)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { updateUserById } from '@/app/actions/users'
import { useHaptic } from 'use-haptic'
import { useActivationStatus } from '@/hooks/useActivationStatus'
import ActivationCTAs from '@/components/Home/ActivationCTAs'
import PendingVerificationTasks from '@/components/Home/PendingVerificationTasks'
import LazyLoadErrorBoundary from '@/components/Global/LazyLoadErrorBoundary'
import underMaintenanceConfig from '@/config/underMaintenance.config'
import posthog from 'posthog-js'
Expand Down Expand Up @@ -214,6 +215,12 @@ export default function Home() {
dismiss/click hides it forever. Rendered above the carousel/activation
CTAs so it leads the home stack on launch day. */}
<CardLaunchCTA />
{/* Pending Bridge verification tasks (ToS / hosted re-verification).
Sibling of ActivationCTAs on purpose: it must show for users
who can already transact (the advisory cohort), whom the
activation card deliberately stands down for. Self-hiding;
dismissible here — resurfaces under Profile → Unlocked regions. */}
<PendingVerificationTasks dismissible />
{isActivated ? (
<HomeCarouselCTA />
) : (
Expand Down
25 changes: 25 additions & 0 deletions src/app/actions/sumsub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,31 @@ export const initiateSelfHealResubmission = async (
}
}

/**
* Exchange the `bridge-hosted` capability action for Bridge's hosted
* verification URL (same POST /users/kyc/start-action endpoint as
* {@link startKycAction}, different response shape: that path mints Sumsub
* tokens, this one returns a URL — hence its own guard).
*/
export const startBridgeHostedVerification = async (): Promise<{ url?: string; error?: string }> => {
try {
const response = await serverFetch('/users/kyc/start-action', {
method: 'POST',
body: JSON.stringify({ key: 'bridge-hosted' }),
})
const responseJson = await response.json()
if (!response.ok) {
return { error: responseJson.userMessage || responseJson.error || 'Failed to start verification' }
}
if (!responseJson.verificationUrl) {
return { error: 'Invalid response from server' }
}
return { url: responseJson.verificationUrl }
} catch (e: unknown) {
return { error: e instanceof Error ? e.message : 'An unexpected error occurred' }
}
}

export interface StartKycActionResponse {
token: string
levelName: string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**
* IframeWrapper message routing — the wrapper reacts ONLY to postMessages
* from ITS OWN iframe (source identity), never to a sibling's. The previous
* guard keyed on `visible`, which (a) let two concurrently-visible wrappers
* fire each other's handlers (double ToS confirms + phantom flow
* transitions) and (b) dropped a real completion that landed in the instant
* a modal was hiding — the acceptance existed at Bridge but was never
* confirmed in-app.
*/
import React from 'react'
import { render, act } from '@testing-library/react'
import IframeWrapper from '../index'

jest.mock('next/navigation', () => ({ useRouter: () => ({ push: jest.fn() }) }))
jest.mock('@/context/ModalsContext', () => ({
useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }),
}))

function findIframe(src: string): HTMLIFrameElement {
// headlessui Dialog portals into document.body — search the document.
const iframe = Array.from(document.querySelectorAll('iframe')).find((f) => f.getAttribute('src') === src)
if (!iframe) throw new Error(`iframe with src ${src} not mounted`)
return iframe
}

function postFrom(source: Window | null, data: unknown) {
act(() => {
window.dispatchEvent(new MessageEvent('message', { data, source: source as MessageEventSource | null }))
})
}

describe('IframeWrapper message routing', () => {
it("handles its OWN iframe's completion and ToS messages", () => {
const onClose = jest.fn()
render(<IframeWrapper src="https://one.test/flow" visible onClose={onClose} />)
const own = findIframe('https://one.test/flow').contentWindow

postFrom(own, { name: 'complete', metadata: { status: 'completed' } })
expect(onClose).toHaveBeenCalledWith('completed')

postFrom(own, { signedAgreementId: 'sig-1' })
expect(onClose).toHaveBeenCalledWith('tos_accepted')
})

it("ignores a SIBLING iframe's message even when BOTH wrappers are visible", () => {
const onCloseA = jest.fn()
const onCloseB = jest.fn()
render(
<>
<IframeWrapper src="https://a.test/tos" visible onClose={onCloseA} />
<IframeWrapper src="https://b.test/hosted" visible onClose={onCloseB} />
</>
)

postFrom(findIframe('https://b.test/hosted').contentWindow, { signedAgreementId: 'sig-b' })
expect(onCloseB).toHaveBeenCalledWith('tos_accepted')
expect(onCloseA).not.toHaveBeenCalled()
})

it('ignores messages with no source (nothing to attribute them to)', () => {
const onClose = jest.fn()
render(<IframeWrapper src="https://one.test/flow" visible onClose={onClose} />)

postFrom(null, { name: 'complete', metadata: { status: 'completed' } })
postFrom(null, { signedAgreementId: 'sig-x' })
expect(onClose).not.toHaveBeenCalled()
})
})
14 changes: 13 additions & 1 deletion src/components/Global/IframeWrapper/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import Modal from '../Modal'
import { Icon, type IconName } from '../Icons/Icon'
import ActionModal from '../ActionModal'
Expand All @@ -18,6 +18,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra
const [isHelpModalOpen, setIsHelpModalOpen] = useState(false)
const [modalVariant, setModalVariant] = useState<'stop-verification' | 'trouble'>('trouble')
const [copied, setCopied] = useState(false)
const iframeRef = useRef<HTMLIFrameElement | null>(null)
const router = useRouter()
const { setIsSupportModalOpen } = useModalsContext()

Expand Down Expand Up @@ -96,6 +97,16 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra
// track completed event from iframe and close the modal
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
// React only to messages from OUR iframe. Several surfaces keep a
// wrapper mounted after a manual close (e.g. the multi-phase KYC
// flow's ToS iframe), and a sibling iframe's completion event would
// otherwise fire BOTH handlers — double ToS confirms + phantom flow
// transitions. Matching on the message SOURCE (not `visible`) keeps
// that protection — even against a sibling that is visible at the
// same time — without dropping a completion that lands in the
// instant the modal is hiding: the acceptance already happened at
// Bridge, and never confirming it strands a stale task.
if (!event.source || event.source !== iframeRef.current?.contentWindow) return
const data = event.data
if (data?.name === 'complete' && data?.metadata?.status === 'completed') {
onClose('completed')
Expand Down Expand Up @@ -136,6 +147,7 @@ const IframeWrapper = ({ src, visible, onClose, closeConfirmMessage }: IFrameWra
<div className="h-full w-full flex-grow overflow-scroll">
<iframe
key={src}
ref={iframeRef}
src={src}
allow="camera *; microphone *; fullscreen *"
style={{ width: '100%', height: '85%', border: 'none' }}
Expand Down
Loading
Loading