diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 07e27f10740..41a49045115 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -650,7 +650,7 @@ const HomeTabs = () => { UnmountOnBlurComponent(children)} /> ) : ( diff --git a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx index 990498d410c..0313ccb81ef 100644 --- a/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx +++ b/app/components/UI/Perps/Views/PerpsClosePositionView/PerpsClosePositionView.tsx @@ -476,7 +476,6 @@ const PerpsClosePositionView: React.FC = () => { { {/* Amount Display */} {/* Amount Slider - Hide when keypad is active */} diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx index 63166e908f1..ae75dd30233 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; -import { render, fireEvent } from '@testing-library/react-native'; +import { render, fireEvent, screen } from '@testing-library/react-native'; +import { PerpsAmountDisplaySelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import PerpsAmountDisplay from './PerpsAmountDisplay'; import { formatPrice, formatPositionSize } from '../../utils/formatUtils'; @@ -38,30 +39,23 @@ describe('PerpsAmountDisplay', () => { }); describe('Rendering', () => { - it('displays amount and max amount with proper formatting', () => { + it('displays amount with proper formatting', () => { // Arrange const amount = '1000'; - const maxAmount = 5000; // Act - const { getByText } = render( - , - ); + const { getByText } = render(); // Assert expect(getByText('$1000')).toBeTruthy(); - expect(getByText('$5000 max')).toBeTruthy(); }); it('displays $0 when amount is empty', () => { // Arrange const emptyAmount = ''; - const maxAmount = 5000; // Act - const { getByText } = render( - , - ); + const { getByText } = render(); // Assert expect(getByText('$0')).toBeTruthy(); @@ -71,15 +65,10 @@ describe('PerpsAmountDisplay', () => { // Arrange - Testing branch coverage for line 72 const label = 'Enter Amount'; const amount = '1000'; - const maxAmount = 10000; // Act const { getByText } = render( - , + , ); // Assert @@ -91,13 +80,11 @@ describe('PerpsAmountDisplay', () => { const tokenAmount = '0.5'; const tokenSymbol = 'ETH'; const amount = '1000'; - const maxAmount = 10000; // Act - const { getByText } = render( + render( { ); // Assert - expect(getByText(`${tokenAmount} ${tokenSymbol}`)).toBeTruthy(); + // There will be 2 elements: one in the main display and one in the token amount section + const tokenElements = screen.getAllByText( + `${tokenAmount} ${tokenSymbol}`, + ); + expect(tokenElements.length).toBe(2); expect(formatPositionSize).toHaveBeenCalledWith(tokenAmount); }); }); @@ -114,15 +105,10 @@ describe('PerpsAmountDisplay', () => { it('shows default warning when showWarning is true and maxAmount is 0', () => { // Arrange const amount = '1000'; - const maxAmount = 0; // Act const { getByText } = render( - , + , ); // Assert @@ -135,13 +121,11 @@ describe('PerpsAmountDisplay', () => { // Arrange const customMessage = 'Insufficient balance'; const amount = '1000'; - const maxAmount = 5000; // Act const { getByText } = render( , @@ -157,15 +141,10 @@ describe('PerpsAmountDisplay', () => { // Arrange const onPressMock = jest.fn(); const amount = '1000'; - const maxAmount = 5000; // Act const { getByText } = render( - , + , ); fireEvent.press(getByText('$1000')); @@ -176,12 +155,9 @@ describe('PerpsAmountDisplay', () => { it('handles press gracefully when onPress is not provided', () => { // Arrange const amount = '1000'; - const maxAmount = 5000; // Act - const { getByText } = render( - , - ); + const { getByText } = render(); // Assert - This should not throw an error expect(() => fireEvent.press(getByText('$1000'))).not.toThrow(); @@ -192,11 +168,10 @@ describe('PerpsAmountDisplay', () => { it('shows cursor when isActive is true', () => { // Arrange const amount = '1000'; - const maxAmount = 5000; // Act const { getByTestId } = render( - , + , ); // Assert @@ -206,19 +181,76 @@ describe('PerpsAmountDisplay', () => { it('hides cursor when isActive is false', () => { // Arrange const amount = '1000'; - const maxAmount = 5000; // Act const { queryByTestId } = render( + , + ); + + // Assert + expect(queryByTestId('cursor')).toBeNull(); + }); + }); + + describe('Token Amount Display', () => { + it('displays token amount when showMaxAmount is true with token data', () => { + // Arrange + const amount = '1000'; + const tokenAmount = '0.025'; + const tokenSymbol = 'BTC'; + + // Act + const { getByText } = render( , ); // Assert - expect(queryByTestId('cursor')).toBeNull(); + expect(getByText('0.025 BTC')).toBeTruthy(); + expect(formatPositionSize).toHaveBeenCalledWith(tokenAmount); + }); + + it('does not display token amount when showMaxAmount is false', () => { + // Arrange + const amount = '1000'; + const tokenAmount = '0.025'; + const tokenSymbol = 'BTC'; + + // Act + const { queryByText } = render( + , + ); + + // Assert + expect(queryByText('0.025 BTC')).toBeNull(); + }); + + it('does not display anything when showMaxAmount is true but no token data', () => { + // Arrange + const amount = '1000'; + + // Act + const { queryByTestId } = render( + , + ); + + // Assert - The component should not show the token amount section + // When no token data is provided, the token amount section won't be rendered + // We verify by checking if the amount display is there but no token text + expect( + queryByTestId(PerpsAmountDisplaySelectorsIDs.CONTAINER), + ).toBeTruthy(); + // No token amount should be displayed + expect(screen.queryByText(/BTC|ETH|SOL/)).toBeNull(); }); }); @@ -226,29 +258,24 @@ describe('PerpsAmountDisplay', () => { it('formats prices with correct decimal places', () => { // Arrange const amount = '1234.56'; - const maxAmount = 9876.54; // Act - render(); + render(); // Assert expect(formatPrice).toHaveBeenCalledWith('1234.56', { minimumDecimals: 0, maximumDecimals: 2, }); - expect(formatPrice).toHaveBeenCalledWith(9876.54, { - minimumDecimals: 2, - maximumDecimals: 2, - }); + // Note: formatPrice is no longer called with maxAmount for display }); it('formats USD amounts with maximum 2 decimal places', () => { // Arrange const amount = '1234.5678'; - const maxAmount = 5000; // Act - render(); + render(); // Assert - Verify USD amounts are limited to 2 decimal places expect(formatPrice).toHaveBeenCalledWith('1234.5678', { diff --git a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx index 649de1661c1..5c0a6fbd749 100644 --- a/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx +++ b/app/components/UI/Perps/components/PerpsAmountDisplay/PerpsAmountDisplay.tsx @@ -8,11 +8,9 @@ import Text, { import { useTheme } from '../../../../../util/theme'; import { formatPrice, formatPositionSize } from '../../utils/formatUtils'; import createStyles from './PerpsAmountDisplay.styles'; -import { strings } from '../../../../../../locales/i18n'; interface PerpsAmountDisplayProps { amount: string; - maxAmount: number; showWarning?: boolean; warningMessage?: string; onPress?: () => void; @@ -26,7 +24,6 @@ interface PerpsAmountDisplayProps { const PerpsAmountDisplay: React.FC = ({ amount, - maxAmount, showWarning = false, warningMessage = 'No funds available. Please deposit first.', onPress, @@ -106,15 +103,15 @@ const PerpsAmountDisplay: React.FC = ({ /> )} - {showMaxAmount && ( + {/* Display token amount equivalent for current input */} + {showMaxAmount && tokenAmount && tokenSymbol && ( - {formatPrice(maxAmount, { minimumDecimals: 2, maximumDecimals: 2 })}{' '} - {strings('perps.order.max')} + {formatPositionSize(tokenAmount)} {tokenSymbol} )} {showWarning && ( diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx index 27bafbb8f13..b59c46b1a11 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.test.tsx @@ -110,6 +110,7 @@ describe('RecipientInput', () => { const { getByText, getByPlaceholderText } = renderWithProvider( , ); @@ -122,6 +123,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -147,6 +149,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -170,7 +173,11 @@ describe('RecipientInput', () => { }); const { getByText } = renderWithProvider( - , + , ); expect(getByText('Paste')).toBeOnTheScreen(); @@ -192,7 +199,11 @@ describe('RecipientInput', () => { }); const { getByDisplayValue } = renderWithProvider( - , + , ); expect(() => getByDisplayValue('0x123...')).toThrow(); @@ -207,10 +218,12 @@ describe('RecipientInput', () => { setRecipientInputMethodSelectContact: jest.fn(), }); + const mockSetIsRecipientSelectedFromList = jest.fn(); const mockSetPastedRecipient = jest.fn(); const { getByPlaceholderText } = renderWithProvider( , ); @@ -224,6 +237,7 @@ describe('RecipientInput', () => { expect(mockUpdateTo).toHaveBeenCalledWith( '0x1234567890123456789012345678901234567890', ); + expect(mockSetIsRecipientSelectedFromList).toHaveBeenCalled(); expect(mockSetPastedRecipient).toHaveBeenCalledWith(undefined); expect(mockSetRecipientInputMethodManual).toHaveBeenCalled(); }); @@ -232,11 +246,13 @@ describe('RecipientInput', () => { const mockAddress = '0x1234567890123456789012345678901234567890'; mockClipboardManager.getString.mockResolvedValue(mockAddress); mockValidateToAddress.mockResolvedValue({ error: 'Invalid address' }); + const mockSetIsRecipientSelectedFromList = jest.fn(); const mockSetPastedRecipient = jest.fn(); const { getByText } = renderWithProvider( , ); @@ -246,6 +262,7 @@ describe('RecipientInput', () => { await waitFor(() => { expect(mockUpdateTo).toHaveBeenCalledWith(mockAddress); + expect(mockSetIsRecipientSelectedFromList).toHaveBeenCalled(); expect(mockSetPastedRecipient).toHaveBeenCalledWith(mockAddress); }); @@ -261,6 +278,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -279,6 +297,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -301,6 +320,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -333,6 +353,7 @@ describe('RecipientInput', () => { const { getByText } = renderWithProvider( , ); @@ -363,6 +384,7 @@ describe('RecipientInput', () => { const { getByText, rerender } = renderWithProvider( , ); @@ -370,7 +392,11 @@ describe('RecipientInput', () => { expect(getByText('Clear')).toBeOnTheScreen(); rerender( - , + , ); expect(getByText('Paste')).toBeOnTheScreen(); }); @@ -379,6 +405,7 @@ describe('RecipientInput', () => { const { getByText, rerender } = renderWithProvider( , ); @@ -402,6 +429,7 @@ describe('RecipientInput', () => { rerender( , ); diff --git a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx index c737c24f13b..8361acf9cb2 100644 --- a/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx +++ b/app/components/Views/confirmations/components/recipient-input/recipient-input.tsx @@ -18,9 +18,11 @@ import { useSendContext } from '../../context/send-context/send-context'; export const RecipientInput = ({ isRecipientSelectedFromList, + setIsRecipientSelectedFromList, setPastedRecipient, }: { isRecipientSelectedFromList: boolean; + setIsRecipientSelectedFromList: (val: boolean) => void; setPastedRecipient: (recipient?: string) => void; }) => { const { to, updateTo } = useSendContext(); @@ -29,6 +31,7 @@ export const RecipientInput = ({ useRecipientSelectionMetrics(); const handlePaste = useCallback(async () => { + setIsRecipientSelectedFromList(false); try { const clipboardText = await ClipboardManager.getString(); if (clipboardText) { @@ -44,8 +47,16 @@ export const RecipientInput = ({ } catch (error) { // Might consider showing an alert here if pasting fails // for now just ignore it + // eslint-disable-next-line no-console + console.log('error while pasting', error); } - }, [updateTo, inputRef, setPastedRecipient, setRecipientInputMethodPasted]); + }, [ + updateTo, + inputRef, + setPastedRecipient, + setIsRecipientSelectedFromList, + setRecipientInputMethodPasted, + ]); const handleClearInput = useCallback(() => { updateTo(''); @@ -56,11 +67,17 @@ export const RecipientInput = ({ const handleTextChange = useCallback( async (toAddress: string) => { + setIsRecipientSelectedFromList(false); updateTo(toAddress); setRecipientInputMethodManual(); setPastedRecipient(undefined); }, - [setPastedRecipient, setRecipientInputMethodManual, updateTo], + [ + setIsRecipientSelectedFromList, + setPastedRecipient, + setRecipientInputMethodManual, + updateTo, + ], ); const defaultStartAccessory = useMemo( diff --git a/app/components/Views/confirmations/components/send/recipient/recipient.tsx b/app/components/Views/confirmations/components/send/recipient/recipient.tsx index 59a3095e015..6d7f8e3f0f3 100644 --- a/app/components/Views/confirmations/components/send/recipient/recipient.tsx +++ b/app/components/Views/confirmations/components/send/recipient/recipient.tsx @@ -133,6 +133,7 @@ export const Recipient = () => { diff --git a/app/core/Authentication/Authentication.ts b/app/core/Authentication/Authentication.ts index e35003ab08c..f4ae76f46fa 100644 --- a/app/core/Authentication/Authentication.ts +++ b/app/core/Authentication/Authentication.ts @@ -70,6 +70,7 @@ import { add0x, bytesToHex, hexToBytes, remove0x } from '@metamask/utils'; import { getTraceTags } from '../../util/sentry/tags'; import { toChecksumHexAddress } from '@metamask/controller-utils'; import AccountTreeInitService from '../../multichain-accounts/AccountTreeInitService'; +import { renewSeedlessControllerRefreshTokens } from '../OAuthService/SeedlessControllerHelper'; import { EntropySourceId } from '@metamask/keyring-api'; /** @@ -124,8 +125,10 @@ class AuthenticationService { if (selectSeedlessOnboardingLoginFlow(ReduxService.store.getState())) { await SeedlessOnboardingController.submitPassword(password); - SeedlessOnboardingController.revokeRefreshToken(password).catch((err) => { - Logger.error(err, 'Failed to revoke refresh token'); + + // renew refresh token + renewSeedlessControllerRefreshTokens(password).catch((err) => { + Logger.error(err, 'Failed to renew refresh token'); }); } password = this.wipeSensitiveData(); @@ -1133,11 +1136,9 @@ class AuthenticationService { }); await KeyringController.changePassword(globalPassword); await this.syncKeyringEncryptionKey(); - SeedlessOnboardingController.revokeRefreshToken(globalPassword).catch( - (err) => { - Logger.error(err, 'Failed to revoke refresh token'); - }, - ); + renewSeedlessControllerRefreshTokens(globalPassword).catch((err) => { + Logger.error(err, 'Failed to renew refresh token'); + }); } catch (err) { // lock app again on error after submitPassword succeeded await this.lockApp({ locked: true }); diff --git a/app/core/Engine/controllers/seedless-onboarding-controller/index.ts b/app/core/Engine/controllers/seedless-onboarding-controller/index.ts index 4062968c04d..53d8e7d7566 100644 --- a/app/core/Engine/controllers/seedless-onboarding-controller/index.ts +++ b/app/core/Engine/controllers/seedless-onboarding-controller/index.ts @@ -53,6 +53,7 @@ export const seedlessOnboardingControllerInit: ControllerInitFunction< network: web3AuthNetwork as Web3AuthNetwork, passwordOutdatedCacheTTL: 15_000, // 15 seconds refreshJWTToken: AuthTokenHandler.refreshJWTToken, + renewRefreshToken: AuthTokenHandler.renewRefreshToken, revokeRefreshToken: AuthTokenHandler.revokeRefreshToken, }); diff --git a/app/core/Engine/messengers/seedless-onboarding-controller-messenger/index.ts b/app/core/Engine/messengers/seedless-onboarding-controller-messenger/index.ts index 622cf570a5a..d8b325285c1 100644 --- a/app/core/Engine/messengers/seedless-onboarding-controller-messenger/index.ts +++ b/app/core/Engine/messengers/seedless-onboarding-controller-messenger/index.ts @@ -15,7 +15,7 @@ export function getSeedlessOnboardingControllerMessenger( ) { return baseControllerMessenger.getRestricted({ name: 'SeedlessOnboardingController', - allowedEvents: ['KeyringController:lock', 'KeyringController:unlock'], + allowedEvents: [], allowedActions: [], }); } diff --git a/app/core/OAuthService/AuthTokenHandler.test.ts b/app/core/OAuthService/AuthTokenHandler.test.ts index d1df8a28efd..563ff05610c 100644 --- a/app/core/OAuthService/AuthTokenHandler.test.ts +++ b/app/core/OAuthService/AuthTokenHandler.test.ts @@ -1,5 +1,9 @@ import { Platform } from 'react-native'; -import AuthTokenHandler from './AuthTokenHandler'; +import AuthTokenHandler, { + AUTH_SERVER_RENEW_PATH, + AUTH_SERVER_REVOKE_PATH, + AUTH_SERVER_TOKEN_PATH, +} from './AuthTokenHandler'; import { AuthConnection } from './OAuthInterface'; import { createLoginHandler } from './OAuthLoginHandlers'; @@ -20,11 +24,13 @@ jest.mock('react-native', () => { const mockPlatform = Platform; +const mockServerUrl = 'https://test-auth-server.com'; + describe('AuthTokenHandler', () => { const mockLoginHandler = { options: { clientId: 'test-client-id', - authServerUrl: 'https://test-auth-server.com', + authServerUrl: mockServerUrl, web3AuthNetwork: 'test-network', }, }; @@ -73,7 +79,7 @@ describe('AuthTokenHandler', () => { mockConnection, ); expect(fetchSpy).toHaveBeenCalledWith( - 'https://test-auth-server.com/api/v1/oauth/token', + `${mockServerUrl}${AUTH_SERVER_TOKEN_PATH}`, { method: 'POST', headers: { @@ -153,7 +159,7 @@ describe('AuthTokenHandler', () => { ).rejects.toThrow(); }); - it('handles missing id_token in response', async () => { + it('throws error when id_token is missing in response', async () => { // Arrange const mockResponse = { access_token: 'new-access-token', @@ -162,26 +168,25 @@ describe('AuthTokenHandler', () => { }; fetchSpy.mockResolvedValueOnce({ - ok: jest.fn().mockResolvedValueOnce(true), + ok: true, json: jest.fn().mockResolvedValueOnce(mockResponse), }); - // Act - const result = await AuthTokenHandler.refreshJWTToken({ - connection: mockConnection, - refreshToken: mockRefreshToken, - }); - - // Assert - expect(result.idTokens).toEqual([undefined]); + // Act & Assert + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: mockConnection, + refreshToken: mockRefreshToken, + }), + ).rejects.toThrow('Failed to refresh JWT token - respoond json'); }); }); - describe('revokeRefreshToken', () => { + describe('renewRefreshToken', () => { const mockRevokeToken = 'test-revoke-token'; const mockConnection = AuthConnection.Google; - it('successfully revokes refresh token with valid parameters', async () => { + it('successfully renews refresh token with valid parameters', async () => { // Arrange const mockResponse = { refresh_token: 'new-refresh-token', @@ -189,12 +194,12 @@ describe('AuthTokenHandler', () => { }; fetchSpy.mockResolvedValueOnce({ - ok: jest.fn().mockResolvedValueOnce(true), + ok: true, json: jest.fn().mockResolvedValueOnce(mockResponse), }); // Act - const result = await AuthTokenHandler.revokeRefreshToken({ + const result = await AuthTokenHandler.renewRefreshToken({ connection: mockConnection, revokeToken: mockRevokeToken, }); @@ -205,7 +210,7 @@ describe('AuthTokenHandler', () => { mockConnection, ); expect(fetchSpy).toHaveBeenCalledWith( - 'https://test-auth-server.com/api/v1/oauth/revoke', + `${mockServerUrl}${AUTH_SERVER_RENEW_PATH}`, { method: 'POST', headers: { @@ -235,7 +240,7 @@ describe('AuthTokenHandler', () => { }); // Act - const result = await AuthTokenHandler.revokeRefreshToken({ + const result = await AuthTokenHandler.renewRefreshToken({ connection: AuthConnection.Apple, revokeToken: mockRevokeToken, }); @@ -255,7 +260,7 @@ describe('AuthTokenHandler', () => { // Act & Assert await expect( - AuthTokenHandler.revokeRefreshToken({ + AuthTokenHandler.renewRefreshToken({ connection: mockConnection, revokeToken: mockRevokeToken, }), @@ -272,7 +277,7 @@ describe('AuthTokenHandler', () => { // Act & Assert await expect( - AuthTokenHandler.revokeRefreshToken({ + AuthTokenHandler.renewRefreshToken({ connection: mockConnection, revokeToken: mockRevokeToken, }), @@ -291,7 +296,7 @@ describe('AuthTokenHandler', () => { }); // Act - const result = await AuthTokenHandler.revokeRefreshToken({ + const result = await AuthTokenHandler.renewRefreshToken({ connection: mockConnection, revokeToken: mockRevokeToken, }); @@ -304,6 +309,94 @@ describe('AuthTokenHandler', () => { }); }); + describe('revokeRefreshToken', () => { + const mockRevokeToken = 'test-revoke-token'; + const mockConnection = AuthConnection.Google; + + it('successfully revokes refresh token with valid parameters', async () => { + // Arrange + fetchSpy.mockResolvedValueOnce({ + ok: true, + }); + + // Act + const result = await AuthTokenHandler.revokeRefreshToken({ + connection: mockConnection, + revokeToken: mockRevokeToken, + }); + + // Assert + expect(mockCreateLoginHandler).toHaveBeenCalledWith( + Platform.OS, + mockConnection, + ); + expect(fetchSpy).toHaveBeenCalledWith( + `${mockServerUrl}${AUTH_SERVER_REVOKE_PATH}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + revoke_token: mockRevokeToken, + }), + }, + ); + expect(result).toBeUndefined(); + }); + + it('handles Apple connection type correctly', async () => { + // Arrange + fetchSpy.mockResolvedValueOnce({ + ok: true, + }); + + // Act + const result = await AuthTokenHandler.revokeRefreshToken({ + connection: AuthConnection.Apple, + revokeToken: mockRevokeToken, + }); + + // Assert + expect(mockCreateLoginHandler).toHaveBeenCalledWith( + Platform.OS, + AuthConnection.Apple, + ); + expect(result).toBeUndefined(); + }); + + it('throws error when fetch request fails', async () => { + // Arrange + const mockError = new Error('Network error'); + fetchSpy.mockRejectedValueOnce(mockError); + + // Act & Assert + await expect( + AuthTokenHandler.revokeRefreshToken({ + connection: mockConnection, + revokeToken: mockRevokeToken, + }), + ).rejects.toThrow('Network error'); + }); + + it('throws error when response is not ok', async () => { + // Arrange + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + // Act & Assert + await expect( + AuthTokenHandler.revokeRefreshToken({ + connection: mockConnection, + revokeToken: mockRevokeToken, + }), + ).rejects.toThrow('Failed to revoke refresh token'); + }); + }); + describe('error handling', () => { it('handles JSON parsing errors in refreshJWTToken', async () => { // Arrange @@ -321,7 +414,7 @@ describe('AuthTokenHandler', () => { ).rejects.toThrow('Invalid JSON'); }); - it('handles JSON parsing errors in revokeRefreshToken', async () => { + it('handles JSON parsing errors in renewRefreshToken', async () => { // Arrange fetchSpy.mockResolvedValueOnce({ ok: true, @@ -330,12 +423,29 @@ describe('AuthTokenHandler', () => { // Act & Assert await expect( - AuthTokenHandler.revokeRefreshToken({ + AuthTokenHandler.renewRefreshToken({ connection: AuthConnection.Google, revokeToken: 'test-token', }), ).rejects.toThrow('Invalid JSON'); }); + + it('revokeRefreshToken does not parse JSON response', async () => { + // Arrange + fetchSpy.mockResolvedValueOnce({ + ok: true, + // No json method needed since revokeRefreshToken doesn't parse response + }); + + // Act + const result = await AuthTokenHandler.revokeRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'test-token', + }); + + // Assert + expect(result).toBeUndefined(); + }); }); describe('platform compatibility', () => { @@ -393,9 +503,9 @@ describe('AuthTokenHandler', () => { refreshToken: 'test-token', }); - await AuthTokenHandler.refreshJWTToken({ + await AuthTokenHandler.renewRefreshToken({ connection: AuthConnection.Apple, - refreshToken: 'test-token', + revokeToken: 'test-token', }); // Assert @@ -412,4 +522,334 @@ describe('AuthTokenHandler', () => { ); }); }); + + describe('request parameter validation', () => { + it('includes all required parameters in refreshJWTToken request body', async () => { + // Arrange + const mockResponse = { + id_token: 'test-token', + access_token: 'test-access', + metadata_access_token: 'test-metadata', + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act + await AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-refresh-token', + }); + + // Assert + const [, requestOptions] = fetchSpy.mock.calls[0]; + const requestBody = JSON.parse(requestOptions.body); + + expect(requestBody).toEqual({ + client_id: 'test-client-id', + login_provider: AuthConnection.Google, + network: 'test-network', + refresh_token: 'test-refresh-token', + grant_type: 'refresh_token', + }); + }); + + it('includes correct request headers for all methods', async () => { + // Arrange + const mockTokenResponse = { + id_token: 'test-token', + access_token: 'test-access', + metadata_access_token: 'test-metadata', + }; + + const mockRenewResponse = { + refresh_token: 'new-refresh', + revoke_token: 'new-revoke', + }; + + fetchSpy + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockTokenResponse), + }) + .mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockRenewResponse), + }) + .mockResolvedValueOnce({ + ok: true, + }); + + // Act + await AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }); + + await AuthTokenHandler.renewRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'test-revoke', + }); + + await AuthTokenHandler.revokeRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'test-revoke', + }); + + // Assert + expect(fetchSpy).toHaveBeenCalledTimes(3); + + // Check headers for all calls + fetchSpy.mock.calls.forEach(([, options]) => { + expect(options.headers).toEqual({ + 'Content-Type': 'application/json', + }); + expect(options.method).toBe('POST'); + }); + }); + }); + + describe('response data transformation', () => { + it('transforms refreshJWTToken response correctly when all fields present', async () => { + // Arrange + const mockResponse = { + id_token: 'jwt-token-123', + access_token: 'access-abc', + metadata_access_token: 'metadata-xyz', + // Extra fields that should be ignored + extra_field: 'should-be-ignored', + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act + const result = await AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }); + + // Assert + expect(result).toEqual({ + idTokens: ['jwt-token-123'], + accessToken: 'access-abc', + metadataAccessToken: 'metadata-xyz', + }); + }); + + it('throws error when required tokens are missing in refreshJWTToken', async () => { + // Arrange + const mockResponse = { + id_token: 'jwt-token-123', + // Missing access_token and metadata_access_token + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act & Assert + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }), + ).rejects.toThrow('Failed to refresh JWT token - respoond json'); + }); + + it('transforms renewRefreshToken response correctly when all fields present', async () => { + // Arrange + const mockResponse = { + refresh_token: 'new-refresh-456', + revoke_token: 'new-revoke-789', + // Extra fields that should be ignored + expires_in: 3600, + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act + const result = await AuthTokenHandler.renewRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'test-revoke', + }); + + // Assert + expect(result).toEqual({ + newRefreshToken: 'new-refresh-456', + newRevokeToken: 'new-revoke-789', + }); + }); + }); + + describe('error response handling', () => { + it('provides specific error messages for different HTTP status codes in refreshJWTToken', async () => { + // Test 401 Unauthorized + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'invalid-token', + }), + ).rejects.toThrow('Failed to refresh JWT token'); + + // Test 500 Internal Server Error + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + }); + + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }), + ).rejects.toThrow('Failed to refresh JWT token'); + }); + + it('provides specific error messages for different HTTP status codes in renewRefreshToken', async () => { + // Test 403 Forbidden + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 403, + statusText: 'Forbidden', + }); + + await expect( + AuthTokenHandler.renewRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'invalid-revoke', + }), + ).rejects.toThrow('Failed to renew refresh token - Forbidden'); + }); + + it('provides specific error messages for different HTTP status codes in revokeRefreshToken', async () => { + // Test 404 Not Found + fetchSpy.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + await expect( + AuthTokenHandler.revokeRefreshToken({ + connection: AuthConnection.Google, + revokeToken: 'nonexistent-token', + }), + ).rejects.toThrow('Failed to revoke refresh token - Not Found'); + }); + }); + + describe('edge cases and boundary conditions', () => { + it('validates all required tokens are present and non-empty', async () => { + // Arrange + const mockResponse = { + id_token: 'valid-id-token', + access_token: 'valid-access-token', + metadata_access_token: 'valid-metadata-token', + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act + const result = await AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }); + + // Assert + expect(result).toEqual({ + idTokens: ['valid-id-token'], + accessToken: 'valid-access-token', + metadataAccessToken: 'valid-metadata-token', + }); + }); + + it('throws error when tokens are empty strings', async () => { + // Arrange + const mockResponse = { + id_token: '', + access_token: '', + metadata_access_token: '', + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act & Assert + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: '', + }), + ).rejects.toThrow('Failed to refresh JWT token - respoond json'); + }); + + it('throws error when tokens are null/undefined', async () => { + // Arrange + const mockResponse = { + id_token: null, + access_token: null, + metadata_access_token: undefined, + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act & Assert + await expect( + AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: 'test-token', + }), + ).rejects.toThrow('Failed to refresh JWT token - respoond json'); + }); + + it('handles very long token values', async () => { + // Arrange + const longToken = 'a'.repeat(5000); // Very long token + const mockResponse = { + id_token: longToken, + access_token: longToken, + metadata_access_token: longToken, + }; + + fetchSpy.mockResolvedValueOnce({ + ok: true, + json: jest.fn().mockResolvedValueOnce(mockResponse), + }); + + // Act + const result = await AuthTokenHandler.refreshJWTToken({ + connection: AuthConnection.Google, + refreshToken: longToken, + }); + + // Assert + expect(result.idTokens[0]).toBe(longToken); + expect(result.accessToken).toBe(longToken); + expect(result.metadataAccessToken).toBe(longToken); + }); + }); }); diff --git a/app/core/OAuthService/AuthTokenHandler.ts b/app/core/OAuthService/AuthTokenHandler.ts index 5b28ecf98c6..e4b97ba1916 100644 --- a/app/core/OAuthService/AuthTokenHandler.ts +++ b/app/core/OAuthService/AuthTokenHandler.ts @@ -2,8 +2,9 @@ import { Platform } from 'react-native'; import { AuthConnection } from './OAuthInterface'; import { createLoginHandler } from './OAuthLoginHandlers'; -const AUTH_SERVER_REVOKE_PATH = '/api/v1/oauth/revoke'; -const AUTH_SERVER_TOKEN_PATH = '/api/v1/oauth/token'; +export const AUTH_SERVER_RENEW_PATH = '/api/v2/oauth/renew_refresh_token'; +export const AUTH_SERVER_REVOKE_PATH = '/api/v2/oauth/revoke'; +export const AUTH_SERVER_TOKEN_PATH = '/api/v1/oauth/token'; class AuthTokenHandler { async refreshJWTToken(params: { @@ -43,6 +44,17 @@ class AuthTokenHandler { const refreshTokenData = await response.json(); const idToken = refreshTokenData.id_token; + if ( + !idToken || + !refreshTokenData.access_token || + !refreshTokenData.metadata_access_token + ) { + throw new Error( + 'Failed to refresh JWT token - respoond json ' + + JSON.stringify(refreshTokenData), + ); + } + return { idTokens: [idToken], accessToken: refreshTokenData.access_token, @@ -50,7 +62,7 @@ class AuthTokenHandler { }; } - async revokeRefreshToken(params: { + async renewRefreshToken(params: { connection: AuthConnection; revokeToken: string; }) { @@ -62,7 +74,7 @@ class AuthTokenHandler { }; const response = await fetch( - `${loginHandler.options.authServerUrl}${AUTH_SERVER_REVOKE_PATH}`, + `${loginHandler.options.authServerUrl}${AUTH_SERVER_RENEW_PATH}`, { method: 'POST', headers: { @@ -73,7 +85,7 @@ class AuthTokenHandler { ); if (!response.ok) { - throw new Error('Failed to revoke refresh token'); + throw new Error('Failed to renew refresh token - ' + response.statusText); } const responseData = await response.json(); @@ -82,6 +94,37 @@ class AuthTokenHandler { newRevokeToken: responseData.revoke_token, }; } + + async revokeRefreshToken(params: { + connection: AuthConnection; + revokeToken: string; + }) { + const { connection, revokeToken } = params; + const loginHandler = createLoginHandler(Platform.OS, connection); + + const requestData = { + revoke_token: revokeToken, + }; + + const response = await fetch( + `${loginHandler.options.authServerUrl}${AUTH_SERVER_REVOKE_PATH}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestData), + }, + ); + + if (!response.ok) { + throw new Error( + 'Failed to revoke refresh token - ' + response.statusText, + ); + } + + return; + } } export default new AuthTokenHandler(); diff --git a/app/core/OAuthService/SeedlessControllerHelper.ts b/app/core/OAuthService/SeedlessControllerHelper.ts new file mode 100644 index 00000000000..c0616aeda40 --- /dev/null +++ b/app/core/OAuthService/SeedlessControllerHelper.ts @@ -0,0 +1,9 @@ +import Engine from '../Engine'; + +export const renewSeedlessControllerRefreshTokens = async ( + password: string, +) => { + const { SeedlessOnboardingController } = Engine.context; + await SeedlessOnboardingController.renewRefreshToken(password); + await SeedlessOnboardingController.revokePendingRefreshTokens(); +}; diff --git a/app/multichain-accounts/discovery.ts b/app/multichain-accounts/discovery.ts index 92ad986254d..c474eb46c73 100644 --- a/app/multichain-accounts/discovery.ts +++ b/app/multichain-accounts/discovery.ts @@ -2,8 +2,18 @@ import { Bip44Account } from '@metamask/account-api'; import { EntropySourceId, KeyringAccount } from '@metamask/keyring-api'; import { MultichainAccountWallet } from '@metamask/multichain-account-service'; import Engine from '../core/Engine'; +import { trace, TraceOperation, TraceName } from '../util/trace'; -export async function discoverAndCreateAccounts( +/** + * Discover and create accounts. + * + * This function is wrapped by {@link discoverAndCreateAccounts} to add tracing + * and should not be called directly. + * + * @param entropySource - Entropy source ID to use for account discovery + * @returns The number of discovered and created accounts. + */ +async function _discoverAndCreateAccounts( entropySource: EntropySourceId, ): Promise { // HACK: Force Snap keyring instantiation. @@ -23,3 +33,21 @@ export async function discoverAndCreateAccounts( // Compute the number of discovered accounts across all account providers. return Object.values(result).reduce((acc, discovered) => acc + discovered, 0); } + +/** + * Discover and create accounts. + * + * @param entropySource - Entropy source ID to use for account discovery + * @returns The number of discovered and created accounts. + */ +export async function discoverAndCreateAccounts( + entropySource: EntropySourceId, +): Promise { + return trace( + { + name: TraceName.DiscoverAccounts, + op: TraceOperation.AccountDiscover, + }, + () => _discoverAndCreateAccounts(entropySource), + ); +} diff --git a/app/util/trace.ts b/app/util/trace.ts index 9ba182aa0f6..324ffcff877 100644 --- a/app/util/trace.ts +++ b/app/util/trace.ts @@ -120,10 +120,11 @@ export enum TraceName { EarnClaimConfirmationScreen = 'Earn Claim Confirmation Screen', EarnPooledStakingClaimTxConfirmed = 'Earn Pooled Staking Claim Tx Confirmed', // Accounts - ShowAccountList = 'Show Account List', + CreateMultichainAccount = 'Create Multichain Account', + DiscoverAccounts = 'Discover Accounts', ShowAccountAddressList = 'Show Account Address List', + ShowAccountList = 'Show Account List', ShowAccountPrivateKeyList = 'Show Account Private Key List', - CreateMultichainAccount = 'Create Multichain Account', // Perps PerpsOpenPosition = 'Perps Open Position', PerpsClosePosition = 'Perps Close Position', @@ -169,6 +170,7 @@ export enum TraceOperation { OnboardingError = 'onboarding.error', // Accounts AccountCreate = 'account.create', + AccountDiscover = 'account.discover', AccountUi = 'account.ui', // Perps PerpsOperation = 'perps.operation', diff --git a/package.json b/package.json index a9dd58b2593..8a06cd3675d 100644 --- a/package.json +++ b/package.json @@ -285,7 +285,7 @@ "@metamask/scure-bip39": "^2.1.0", "@metamask/sdk-analytics": "0.0.5", "@metamask/sdk-communication-layer": "0.33.0", - "@metamask/seedless-onboarding-controller": "2.6.0", + "@metamask/seedless-onboarding-controller": "^4.0.0", "@metamask/selected-network-controller": "^22.1.0", "@metamask/signature-controller": "^32.0.0", "@metamask/slip44": "^4.2.0", diff --git a/yarn.lock b/yarn.lock index ff63d95097f..3feeb899ea5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5973,13 +5973,13 @@ utf-8-validate "^5.0.2" uuid "^8.3.2" -"@metamask/seedless-onboarding-controller@2.6.0": - version "2.6.0" - resolved "https://registry.yarnpkg.com/@metamask/seedless-onboarding-controller/-/seedless-onboarding-controller-2.6.0.tgz#0e0145ece5d0963626cfe788fa574608e0cde64d" - integrity sha512-oD+ywbKiTnOszOZSHIEf32dfNVo1RvtBIdL71t6GvUlhExAIQXT69pbc89oVvmnGXuOypN3rYyBYYtCWf2RIBg== +"@metamask/seedless-onboarding-controller@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@metamask/seedless-onboarding-controller/-/seedless-onboarding-controller-4.0.0.tgz#a433f06dbe0d72e77af3018d7d9352a55035ac22" + integrity sha512-coLkKhUQ3Eg5MwpHTm/UxPCFLmhPITziKt78I+Vx/0xCJghNsb59K9vJlWaVx8+boESISj5YD6K2KI2NTwNZeA== dependencies: "@metamask/auth-network-utils" "^0.3.0" - "@metamask/base-controller" "^8.1.0" + "@metamask/base-controller" "^8.3.0" "@metamask/toprf-secure-backup" "^0.7.1" "@metamask/utils" "^11.4.2" "@noble/ciphers" "^1.3.0"