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
2 changes: 2 additions & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ dependencies {
implementation project(':capacitor-browser')
implementation project(':capacitor-camera')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-device')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-preferences')
implementation project(':capacitor-splash-screen')
implementation project(':capacitor-status-bar')
implementation project(':capgo-capacitor-crisp')
implementation project(':capgo-capacitor-native-biometric')
implementation project(':capgo-capacitor-passkey')
implementation project(':capgo-capacitor-updater')
implementation project(':onesignal-capacitor-plugin')
Expand Down
6 changes: 6 additions & 0 deletions android/capacitor.settings.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ project(':capacitor-camera').projectDir = new File('../node_modules/.pnpm/@capac
include ':capacitor-clipboard'
project(':capacitor-clipboard').projectDir = new File('../node_modules/.pnpm/@capacitor+clipboard@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/clipboard/android')

include ':capacitor-device'
project(':capacitor-device').projectDir = new File('../node_modules/.pnpm/@capacitor+device@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/device/android')

include ':capacitor-haptics'
project(':capacitor-haptics').projectDir = new File('../node_modules/.pnpm/@capacitor+haptics@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/haptics/android')

Expand All @@ -32,6 +35,9 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/.pnpm/@c
include ':capgo-capacitor-crisp'
project(':capgo-capacitor-crisp').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp/android')

include ':capgo-capacitor-native-biometric'
project(':capgo-capacitor-native-biometric').projectDir = new File('../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric/android')

include ':capgo-capacitor-passkey'
project(':capgo-capacitor-passkey').projectDir = new File('../node_modules/@capgo/capacitor-passkey/android')

Expand Down
2 changes: 1 addition & 1 deletion ios/App/App/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
<key>NSCameraUsageDescription</key>
<string>Peanut uses the camera to scan QR codes and verify your identity.</string>
<key>NSFaceIDUsageDescription</key>
<string>Peanut uses Face ID to securely sign in with your passkey.</string>
<string>Peanut uses Face ID to sign in with your passkey and to unlock your session.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Peanut needs photo access to upload identity documents during verification.</string>
<key>NSLocationWhenInUseUsageDescription</key>
Expand Down
2 changes: 2 additions & 0 deletions ios/App/CapApp-SPM/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ let package = Package(
.package(name: "CapacitorSplashScreen", path: "../../../node_modules/.pnpm/@capacitor+splash-screen@8.0.1_@capacitor+core@8.2.0/node_modules/@capacitor/splash-screen"),
.package(name: "CapacitorStatusBar", path: "../../../node_modules/.pnpm/@capacitor+status-bar@8.0.2_@capacitor+core@8.2.0/node_modules/@capacitor/status-bar"),
.package(name: "CapgoCapacitorCrisp", path: "../../../node_modules/.pnpm/@capgo+capacitor-crisp@8.0.27_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-crisp"),
.package(name: "CapgoCapacitorNativeBiometric", path: "../../../node_modules/.pnpm/@capgo+capacitor-native-biometric@8.6.0_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-native-biometric"),
.package(name: "CapgoCapacitorPasskey", path: "../../../node_modules/@capgo/capacitor-passkey"),
.package(name: "CapgoCapacitorUpdater", path: "../../../node_modules/.pnpm/@capgo+capacitor-updater@8.45.9_@capacitor+core@8.2.0/node_modules/@capgo/capacitor-updater"),
.package(name: "OnesignalCapacitorPlugin", path: "../../../node_modules/@onesignal/capacitor-plugin"),
Expand All @@ -45,6 +46,7 @@ let package = Package(
.product(name: "CapacitorSplashScreen", package: "CapacitorSplashScreen"),
.product(name: "CapacitorStatusBar", package: "CapacitorStatusBar"),
.product(name: "CapgoCapacitorCrisp", package: "CapgoCapacitorCrisp"),
.product(name: "CapgoCapacitorNativeBiometric", package: "CapgoCapacitorNativeBiometric"),
.product(name: "CapgoCapacitorPasskey", package: "CapgoCapacitorPasskey"),
.product(name: "CapgoCapacitorUpdater", package: "CapgoCapacitorUpdater"),
.product(name: "OnesignalCapacitorPlugin", package: "OnesignalCapacitorPlugin"),
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"@capacitor/splash-screen": "^8.0.1",
"@capacitor/status-bar": "^8.0.2",
"@capgo/capacitor-crisp": "^8.0.27",
"@capgo/capacitor-native-biometric": "8.6.0",
"@capgo/capacitor-passkey": "^8.2.2",
"@capgo/capacitor-updater": "^8.45.9",
"@headlessui/react": "^2.2.9",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion src/app/actions/onramp-quote.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { fetchWithSentry } from '@/utils/sentry.utils'
import { AccountType } from '@/interfaces/interfaces'
import { PEANUT_API_URL } from '@/constants/general.consts'
import { getAuthHeaders } from '@/utils/auth-token'
import { authReady, getAuthHeaders } from '@/utils/auth-token'

export interface OnrampQuoteResponse {
from: string
Expand Down Expand Up @@ -34,6 +34,9 @@ export async function getOnrampQuote(
url.searchParams.append('sourceAmount', String(sourceAmount))
}

// park until the session token can legitimately be read (guarded mode
// holds this until unlock) so this caller never fires unauthenticated
await authReady()
const response = await fetchWithSentry(url.toString(), {
method: 'GET',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
Expand Down
132 changes: 132 additions & 0 deletions src/components/Global/AppLock/__tests__/app-lock-gate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// Regression tests for the AppLockGate decision flow. The critical invariant
// (D7): in guarded mode the gate locks from the storage mode ALONE — it must
// never wait for the user query, which cannot settle while its request is
// parked behind the lock. Getting that wrong is a permanent white screen.

import { render, screen, waitFor } from '@testing-library/react'
import { NextIntlClientProvider } from 'next-intl'
import en from '@/i18n/app/messages/en.json'
import { AppLockGate } from '..'
import { getSessionMode, suspendAuthSession, unlockGuardedToken } from '@/utils/auth-token'
import { isCapacitor } from '@/utils/capacitor'

jest.mock('@/utils/capacitor', () => ({
isCapacitor: jest.fn(),
}))

// The app-open lock is dormant behind OPEN_GATED (off unless
// NEXT_PUBLIC_APP_OPEN_GATED=true). Defaults off here too; cases that exercise
// the lock opt in explicitly.
let mockOpenGated = false
jest.mock('@/constants/app-lock.consts', () => ({
get OPEN_GATED() {
return mockOpenGated
},
}))

jest.mock('@/context/authContext', () => ({
useAuth: () => ({
user: null,
// never settles — the whole point: guarded mode must not depend on it
isFetchingUser: true,
logoutUser: jest.fn(),
}),
}))

jest.mock('@/utils/auth-token', () => ({
getSessionMode: jest.fn(),
migratePlainToGuarded: jest.fn(async () => undefined),
suspendAuthSession: jest.fn(),
unlockGuardedToken: jest.fn(),
}))

jest.mock('@/utils/app-lock', () => ({
LOCK_AFTER_BACKGROUND_MS: 5 * 60 * 1000,
requestLocalUserPresence: jest.fn(),
}))

const mockIsCapacitor = isCapacitor as jest.MockedFunction<typeof isCapacitor>
const mockGetSessionMode = getSessionMode as jest.MockedFunction<typeof getSessionMode>
const mockSuspend = suspendAuthSession as jest.MockedFunction<typeof suspendAuthSession>
const mockUnlock = unlockGuardedToken as jest.MockedFunction<typeof unlockGuardedToken>

function renderGate() {
return render(
<NextIntlClientProvider locale="en" messages={en}>
<AppLockGate>
<div data-testid="protected">protected content</div>
</AppLockGate>
</NextIntlClientProvider>
)
}

describe('AppLockGate', () => {
beforeEach(() => {
jest.clearAllMocks()
// Default off, matching production when NEXT_PUBLIC_APP_OPEN_GATED is
// unset. Cases that exercise the lock opt in explicitly.
mockOpenGated = false
})

it('renders children directly on web', () => {
mockIsCapacitor.mockReturnValue(false)
renderGate()
expect(screen.getByTestId('protected')).toBeInTheDocument()
expect(mockGetSessionMode).not.toHaveBeenCalled()
})

it('OPEN_GATED off (default): guarded session opens straight through, never locks', async () => {
mockOpenGated = false
mockIsCapacitor.mockReturnValue(true)
mockGetSessionMode.mockResolvedValue('guarded')

renderGate()
await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument())
expect(mockGetSessionMode).not.toHaveBeenCalled()
expect(mockSuspend).not.toHaveBeenCalled()
})

it('guarded mode: locks and suspends the session without waiting for the user query (D7)', async () => {
mockOpenGated = true
mockIsCapacitor.mockReturnValue(true)
mockGetSessionMode.mockResolvedValue('guarded')
// keep the auto-prompt pending so the locked UI stays put
mockUnlock.mockReturnValue(new Promise(() => {}))

renderGate()
await waitFor(() => expect(screen.getByText(en.appLock.title)).toBeInTheDocument())
expect(mockSuspend).toHaveBeenCalled()
expect(screen.queryByTestId('protected')).not.toBeInTheDocument()
})

it('guarded mode: opens after a successful unlock', async () => {
mockOpenGated = true
mockIsCapacitor.mockReturnValue(true)
mockGetSessionMode.mockResolvedValue('guarded')
mockUnlock.mockResolvedValue('unlocked')

renderGate()
await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument())
})

it('guarded mode: stays locked when the prompt is cancelled', async () => {
mockOpenGated = true
mockIsCapacitor.mockReturnValue(true)
mockGetSessionMode.mockResolvedValue('guarded')
mockUnlock.mockResolvedValue('cancelled')

renderGate()
await waitFor(() => expect(screen.getByText(en.appLock.promptFailed)).toBeInTheDocument())
expect(screen.queryByTestId('protected')).not.toBeInTheDocument()
})

it('none mode: nothing to protect, opens straight through', async () => {
mockOpenGated = true
mockIsCapacitor.mockReturnValue(true)
mockGetSessionMode.mockResolvedValue('none')

renderGate()
await waitFor(() => expect(screen.getByTestId('protected')).toBeInTheDocument())
expect(mockSuspend).not.toHaveBeenCalled()
})
})
Loading
Loading