Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { Notifier } from 'react-native-notifier'
import { OnboardingStep, useCardStore } from '@perawallet/wallet-core-card'
import { mockOauthChain } from '@perawallet/wallet-core-card/test-handlers'

import { server } from '@test-utils/msw-server'
import { renderWithNavigation } from '@test-utils/renderWithNavigation'
Expand Down Expand Up @@ -145,6 +146,9 @@ describe('Flow: Card onboarding — residential address', () => {
{ status: 200 },
),
),
// The registration-issued token is traded for the durable OAuth
// pair (initiate → authorize → token), same chain as sign-in.
...mockOauthChain(),
)
})
afterEach(() => server.resetHandlers())
Expand Down
63 changes: 60 additions & 3 deletions apps/mobile/src/__integration__/card-sign-in.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { http, HttpResponse } from 'msw'
import { View } from 'react-native'
import { Notifier } from 'react-native-notifier'

import { mockOauthChain } from '@perawallet/wallet-core-card/test-handlers'

import { server } from '@test-utils/msw-server'
import { renderWithNavigation } from '@test-utils/renderWithNavigation'
import { CardSignInScreen } from '@modules/card/screens/CardSignInScreen'
Expand Down Expand Up @@ -53,6 +55,10 @@ const renderSignIn = () =>
],
})

// The post-login OAuth code+PKCE chain (initiate → authorize with echoed
// CSRF state → token exchange) is stubbed by the package's mockOauthChain.
const stubOauthChain = () => server.use(...mockOauthChain())

// Enter a valid email + password and wait for the Sign In button to enable.
const fillCredentials = async () => {
fireEvent.change(screen.getByTestId('card-sign-in-email-input'), {
Expand All @@ -74,7 +80,7 @@ describe('Flow: Card sign in', () => {
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('Given valid credentials and a ready account, when Sign In is pressed, then the session is created and the wallet home opens', async () => {
it('Given valid credentials and a ready account, when Sign In is pressed, then the OAuth exchange runs and the wallet home opens', async () => {
const loginSpy = vi.fn(() =>
HttpResponse.json(
{
Expand All @@ -88,6 +94,7 @@ describe('Flow: Card sign in', () => {
),
)
server.use(http.post('*/v1/auth/login', loginSpy))
stubOauthChain()

renderSignIn()
await fillCredentials()
Expand All @@ -100,6 +107,40 @@ describe('Flow: Card sign in', () => {
expect(Notifier.showNotification).toHaveBeenCalled()
})

it('Given the OAuth exchange fails after a valid login, when Sign In is pressed, then a fallback session is created and the wallet home still opens', async () => {
// Credentials were accepted, so an OAuth-proxy outage must degrade to
// the refresh-less 6h session (pre-OAuth behavior), not fail login.
server.use(
http.post('*/v1/auth/login', () =>
HttpResponse.json(
{
accessToken: 'access-token',
userId: 'user-1',
isOtpRequired: false,
phase: null,
isLinked: true,
},
{ status: 200 },
),
),
// Initiate rejects — the chain never reaches the token exchange.
http.get('*/baanx/oauth/initiate', () =>
HttpResponse.json(
{ message: 'not configured' },
{ status: 500 },
),
),
)

renderSignIn()
await fillCredentials()
fireEvent.click(screen.getByTestId('card-sign-in-submit'))

await waitFor(() =>
expect(screen.getByTestId('home-tab-stub')).toBeTruthy(),
)
})

it('Given a mid-registration login for an unverified user, when Sign In is pressed, then the KYC entry screen opens', async () => {
// A mid-onboarding login returns a `phase` but no accessToken and a null
// verificationState; the app reads the real KYC state from the pre-auth
Expand Down Expand Up @@ -207,15 +248,20 @@ describe('Flow: Card sign in', () => {
)
})

it('Given the account requires a 2FA code, when Sign In is pressed, then the code input appears and a valid code completes sign-in', async () => {
it('Given the account requires a 2FA code, when Sign In is pressed, then the code is sent, the input appears, and a valid code completes sign-in', async () => {
let calls = 0
let otpRequestUserId: string | null = null
server.use(
http.post('*/v1/auth/login', () => {
calls += 1
// First attempt: credentials accepted but a code is required.
if (calls === 1) {
return HttpResponse.json(
{ accessToken: null, isOtpRequired: true },
{
accessToken: null,
userId: 'user-2fa',
isOtpRequired: true,
},
{ status: 200 },
)
}
Expand All @@ -225,14 +271,24 @@ describe('Flow: Card sign in', () => {
{ status: 200 },
)
}),
// Baanx does not send the code on its own — the app must request
// it through the OTP endpoint, keyed on the login userId.
http.post('*/v1/auth/login/otp', async ({ request }) => {
const body = (await request.json()) as { userId?: string }
otpRequestUserId = body.userId ?? null
return HttpResponse.json({ success: true }, { status: 200 })
}),
)
stubOauthChain()

renderSignIn()
await fillCredentials()
fireEvent.click(screen.getByTestId('card-sign-in-submit'))

// A full code auto-submits via the input's onComplete.
const otp = await screen.findByTestId('card-sign-in-otp-input')
// The 2FA code was requested for the right user before entry.
await waitFor(() => expect(otpRequestUserId).toBe('user-2fa'))
fireEvent.change(otp, { target: { value: '123456' } })

await waitFor(() => expect(calls).toBe(2))
Expand All @@ -259,6 +315,7 @@ describe('Flow: Card sign in', () => {
),
),
)
stubOauthChain()

renderSignIn()
await fillCredentials()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'

const mockMutateAsync = vi.fn()
const mockSetOnboardingStep = vi.fn()
const mockSendOtpMutateAsync = vi.fn()
vi.mock('@perawallet/wallet-core-card', async () => {
const actual = await vi.importActual<
typeof import('@perawallet/wallet-core-card')
Expand All @@ -37,6 +38,16 @@ vi.mock('@perawallet/wallet-core-card', async () => {
setOnboardingStep: mockSetOnboardingStep,
}),
}),
useSendLoginOtpMutation: () => ({
mutate: vi.fn(),
mutateAsync: mockSendOtpMutateAsync,
isPending: false,
isError: false,
isSuccess: false,
error: null,
data: null,
reset: vi.fn(),
}),
}
})

Expand Down Expand Up @@ -215,4 +226,16 @@ describe('useCardSignInScreen', () => {
expect(mockNavigate).not.toHaveBeenCalled()
})
})

it('does not request a resend before a login has required OTP', async () => {
// The OTP endpoint is keyed on the login's userId; without one (no
// isOtpRequired login yet) resend must be a no-op, not a bad request.
const { result } = renderHook(() => useCardSignInScreen())

await act(async () => {
result.current.handleResendOtp()
})

expect(mockSendOtpMutateAsync).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
signInSchema,
useCardLoginMutation,
useCardStore,
useSendLoginOtpMutation,
type SignInFormValues,
} from '@perawallet/wallet-core-card'
import { useAppNavigation } from '@hooks/useAppNavigation'
Expand Down Expand Up @@ -60,6 +61,7 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
const { errorToast, successToast, infoToast } = useToast()
const navigation = useAppNavigation()
const login = useCardLoginMutation()
const sendOtp = useSendLoginOtpMutation()

const {
control,
Expand All @@ -74,6 +76,9 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
})

const [isOtpRequired, setIsOtpRequired] = useState(false)
// `userId` from the login attempt that required 2FA — /v1/auth/login/otp
// is keyed on it for both the initial send and resends.
const [otpUserId, setOtpUserId] = useState<string | null>(null)
const [otpCode, setOtpCode] = useState('')
const [hasOtpError, setHasOtpError] = useState(false)
const { secondsRemaining, isActive, restart } = useCountdown(
Expand All @@ -89,9 +94,31 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
[hasOtpError],
)

// The single login call handles both passes: the first (no `otp`) may come
// back `isOtpRequired`, which reveals the code input; the second carries the
// code. The session is persisted inside the mutation's `onSuccess`.
// Asks Baanx to send (or re-send) the 2FA code. Baanx does not send it on
// its own when login returns `isOtpRequired` — this call is what triggers
// the SMS. The resend cooldown arms only when the send succeeds; a failure
// surfaces as a toast and leaves the button usable for an immediate retry.
// Depends on the stable `mutateAsync` (not the per-render mutation object)
// so the callback chain below isn't recreated every keystroke.
const sendOtpAsync = sendOtp.mutateAsync
const requestOtpCode = useCallback(
(userId: string) => {
sendOtpAsync({ userId })
.then(() => restart())
.catch(() => {
errorToast(
t('peraCard.sign_in.error_title'),
t('peraCard.sign_in.error_body'),
)
})
},
[sendOtpAsync, restart, errorToast, t],
)

// The login call handles both passes: the first (no `otp`) may come back
// `isOtpRequired`, which triggers the OTP send and reveals the code input;
// the second carries the code and completes the OAuth exchange. The session
// is persisted inside the mutation's `onSuccess`.
const performLogin = useCallback(
async (email: string, password: string, otp?: string) => {
try {
Expand All @@ -101,11 +128,22 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
otpCode: otp,
})

// Credentials accepted but a 2FA code is required — reveal the
// code input and arm the resend cooldown.
// Credentials accepted but a 2FA code is required — ask Baanx
// to send the code and reveal the input (the send arms the
// resend cooldown when it succeeds). Without a userId the code
// can never be requested, so surface an error instead of an
// input no code will reach.
if (result.isOtpRequired && !result.accessToken) {
if (!result.userId) {
errorToast(
t('peraCard.sign_in.error_title'),
t('peraCard.sign_in.error_body'),
)
return
}
setIsOtpRequired(true)
restart()
setOtpUserId(result.userId)
requestOtpCode(result.userId)
return
}

Expand Down Expand Up @@ -185,7 +223,15 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
)
}
},
[login, navigation, setError, errorToast, successToast, restart, t],
[
login,
navigation,
setError,
errorToast,
successToast,
requestOtpCode,
t,
],
)

const handleSignIn = useCallback(
Expand All @@ -205,15 +251,17 @@ export const useCardSignInScreen = (): UseCardSignInScreenResult => {
)

const handleResendOtp = useCallback(() => {
// Re-issuing the code: Baanx re-sends when login runs again without an
// otpCode. TODO(card): confirm this is the intended resend behavior.
if (login.isPending) return
// Re-issue the code through the dedicated OTP endpoint — no need to
// re-run the whole login. The cooldown re-arms inside requestOtpCode
// only if the send succeeds. Blocked while a send OR the OTP-carrying
// login is in flight, so a fresh code can't race the verification of
// the one being checked.
if (!otpUserId || sendOtp.isPending || login.isPending) return
// A fresh code is on its way — clear any "wrong code" error from the
// prior attempt so the re-armed input starts clean.
setHasOtpError(false)
const { email, password } = getValues()
void performLogin(email, password)
}, [login.isPending, getValues, performLogin])
requestOtpCode(otpUserId)
}, [otpUserId, sendOtp.isPending, login.isPending, requestOtpCode])

const handleForgotPassword = useCallback(() => {
// TODO(card): wire to the real forgot-password flow once designed.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/*
Copyright 2022-2026 Pera Wallet, LDA
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License
*/

import { describe, expect, it, beforeEach } from 'vitest'
import { useAppIntegrityStore } from '../../store'
import { getValidIntegrityToken } from '../getValidIntegrityToken'

const registration = (expiresAt: string) => ({
integrityToken: 'attestation-jwt',
expiresAt,
keyId: 'k1',
deviceId: 'd1',
})

describe('getValidIntegrityToken', () => {
beforeEach(() => {
useAppIntegrityStore.getState().resetState()
})

it('returns the token while it is unexpired', () => {
const future = new Date(Date.now() + 60_000).toISOString()
useAppIntegrityStore.getState().setRegistration(registration(future))

expect(getValidIntegrityToken()).toBe('attestation-jwt')
})

it('returns null when the token is expired', () => {
const past = new Date(Date.now() - 60_000).toISOString()
useAppIntegrityStore.getState().setRegistration(registration(past))

expect(getValidIntegrityToken()).toBeNull()
})

it('returns null when no registration is stored', () => {
expect(getValidIntegrityToken()).toBeNull()
})

it('returns null for an unparseable expiry', () => {
useAppIntegrityStore
.getState()
.setRegistration(registration('not-a-date'))

expect(getValidIntegrityToken()).toBeNull()
})
})
Loading
Loading