diff --git a/.github/workflows/build-android-upload-to-browserstack.yml b/.github/workflows/build-android-upload-to-browserstack.yml index f843960b2d2..fae86af8478 100644 --- a/.github/workflows/build-android-upload-to-browserstack.yml +++ b/.github/workflows/build-android-upload-to-browserstack.yml @@ -257,7 +257,7 @@ jobs: ENV_VARS=$(jq -n \ --arg byoa "${{ secrets.E2E_BYOA_AUTH_SECRET }}" \ --arg email "${{ secrets.E2E_MOCK_OAUTH_EMAIL }}" \ - '[{"mapped_to":"DISABLE_NOTIFICATION_PROMPT","value":"true","is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH","value":"true","is_expand":true},{"mapped_to":"E2E_BYOA_AUTH_SECRET","value":$byoa,"is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH_EMAIL","value":$email,"is_expand":true}]') + '[{"mapped_to":"DISABLE_NOTIFICATION_PROMPT","value":"true","is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH","value":"true","is_expand":true},{"mapped_to":"E2E_BYOA_AUTH_SECRET","value":$byoa,"is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH_EMAIL","value":$email,"is_expand":true},{"mapped_to":"OAUTH_BUILD_TYPE","value":"main_uat","is_expand":true}]') CUSTOM_ID="MetaMask-Android-Without-SRP-${{ github.run_id }}" # Trigger Android workflow diff --git a/.github/workflows/build-ios-upload-to-browserstack.yml b/.github/workflows/build-ios-upload-to-browserstack.yml index 580f1f38b32..dc11ecbe760 100644 --- a/.github/workflows/build-ios-upload-to-browserstack.yml +++ b/.github/workflows/build-ios-upload-to-browserstack.yml @@ -258,7 +258,7 @@ jobs: ENV_VARS=$(jq -n \ --arg byoa "${{ secrets.E2E_BYOA_AUTH_SECRET }}" \ --arg email "${{ secrets.E2E_MOCK_OAUTH_EMAIL }}" \ - '[{"mapped_to":"DISABLE_NOTIFICATION_PROMPT","value":"true","is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH","value":"true","is_expand":true},{"mapped_to":"E2E_BYOA_AUTH_SECRET","value":$byoa,"is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH_EMAIL","value":$email,"is_expand":true}]') + '[{"mapped_to":"DISABLE_NOTIFICATION_PROMPT","value":"true","is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH","value":"true","is_expand":true},{"mapped_to":"E2E_BYOA_AUTH_SECRET","value":$byoa,"is_expand":true},{"mapped_to":"E2E_MOCK_OAUTH_EMAIL","value":$email,"is_expand":true},{"mapped_to":"OAUTH_BUILD_TYPE","value":"main_uat","is_expand":true}]') CUSTOM_ID="MetaMask-iOS-Without-SRP-${{ github.run_id }}" # Trigger iOS workflow diff --git a/android/app/src/main/res/xml/react_native_config.xml b/android/app/src/main/res/xml/react_native_config.xml index aa2ca91bf79..6daea18d800 100644 --- a/android/app/src/main/res/xml/react_native_config.xml +++ b/android/app/src/main/res/xml/react_native_config.xml @@ -1,10 +1,15 @@ + + + + + + - sslip.io + sslip.io localhost 10.0.2.2 10.0.3.2 - diff --git a/app/core/OAuthService/OAuthLoginHandlers/config.ts b/app/core/OAuthService/OAuthLoginHandlers/config.ts index 60e96839a6b..fec41aedd0c 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/config.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/config.ts @@ -10,7 +10,7 @@ interface OAUTH_CONFIG_TYPE { IOS_APPLE_AUTH_CONNECTION_ID: string; } -enum BUILD_TYPE { +export enum BUILD_TYPE { development = 'development', main_prod = 'main_prod', main_uat = 'main_uat', diff --git a/app/core/OAuthService/OAuthLoginHandlers/constants.ts b/app/core/OAuthService/OAuthLoginHandlers/constants.ts index 6e34dbdd129..fa57008532e 100644 --- a/app/core/OAuthService/OAuthLoginHandlers/constants.ts +++ b/app/core/OAuthService/OAuthLoginHandlers/constants.ts @@ -1,10 +1,10 @@ import { ACTIONS, PREFIXES, PROTOCOLS } from '../../../constants/deeplinks'; import Device from '../../../util/device'; import ReduxService from '../../redux'; -import { isQa } from '../../../util/test/utils'; import AppConstants from '../../AppConstants'; import { AuthConnection } from '../OAuthInterface'; import { OAUTH_CONFIG } from './config'; +import { resolveOAuthConfigKey } from './oauthBuildType'; import { DEFAULT_LEGACY_IOS_GOOGLE_CONFIG_ENABLED, selectLegacyIosGoogleConfigEnabled, @@ -13,45 +13,8 @@ import { export const SEEDLESS_ONBOARDING_ENABLED = process.env.SEEDLESS_ONBOARDING_ENABLED === 'true'; -/** - * Mapping of old Build Type to new BuildType formatting for oauth config - * Main -> main_prod - * QA -> main_uat - * Debug -> main_dev - * flask -> flask_prod - * flask QA -> flask_uat - * flask Debug -> flask_dev - * - * new build types - * main_beta -> main_prod - * main_rc -> main_prod - * - * @param buildType - The build type to map - * @param isDev - Whether the build is a development build - * @returns The mapped build type - */ -const buildTypeMapping = (buildType: string, isDev: boolean) => { - // use development config for now - if (process.env.DEV_OAUTH_CONFIG === 'true' && isDev) { - return 'development'; - } - - switch (buildType) { - case 'qa': - return 'main_uat'; - case 'main': - return isQa ? 'main_uat' : isDev ? 'main_dev' : 'main_prod'; - case 'flask': - return isQa ? 'flask_uat' : isDev ? 'flask_dev' : 'flask_prod'; - default: - return 'development'; - } -}; - -const BuildType = buildTypeMapping( - AppConstants.METAMASK_BUILD_TYPE || 'main', - AppConstants.IS_DEV, -); +/** OAuth config key: env override or build-type mapping */ +const BuildType = resolveOAuthConfigKey(); const CURRENT_OAUTH_CONFIG = OAUTH_CONFIG[BuildType]; export const web3AuthNetwork = CURRENT_OAUTH_CONFIG.WEB3AUTH_NETWORK; diff --git a/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.test.ts b/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.test.ts new file mode 100644 index 00000000000..d0e113dd47d --- /dev/null +++ b/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.test.ts @@ -0,0 +1,83 @@ +import { BUILD_TYPE, OAUTH_CONFIG } from './config'; +import { buildTypeMapping } from './oauthBuildType'; + +describe('buildTypeMapping', () => { + const originalDevOAuth = process.env.DEV_OAUTH_CONFIG; + + afterEach(() => { + if (originalDevOAuth === undefined) { + delete process.env.DEV_OAUTH_CONFIG; + } else { + process.env.DEV_OAUTH_CONFIG = originalDevOAuth; + } + }); + + it('returns development when DEV_OAUTH_CONFIG is true and isDev', () => { + process.env.DEV_OAUTH_CONFIG = 'true'; + expect(buildTypeMapping('main', true, false)).toBe(BUILD_TYPE.development); + }); + + it('maps qa to main_uat', () => { + expect(buildTypeMapping('qa', false, false)).toBe(BUILD_TYPE.main_uat); + }); + + it('maps main with QA channel to main_uat', () => { + expect(buildTypeMapping('main', false, true)).toBe(BUILD_TYPE.main_uat); + }); + + it('maps main without QA to main_dev when isDev', () => { + expect(buildTypeMapping('main', true, false)).toBe(BUILD_TYPE.main_dev); + }); + + it('maps main without QA to main_prod when not isDev', () => { + expect(buildTypeMapping('main', false, false)).toBe(BUILD_TYPE.main_prod); + }); + + it('maps flask with QA channel to flask_uat', () => { + expect(buildTypeMapping('flask', false, true)).toBe(BUILD_TYPE.flask_uat); + }); + + it('maps flask without QA to flask_dev when isDev', () => { + expect(buildTypeMapping('flask', true, false)).toBe(BUILD_TYPE.flask_dev); + }); + + it('maps flask without QA to flask_prod when not isDev', () => { + expect(buildTypeMapping('flask', false, false)).toBe(BUILD_TYPE.flask_prod); + }); + + it('returns development for unknown build type', () => { + expect(buildTypeMapping('unknown', false, false)).toBe( + BUILD_TYPE.development, + ); + }); +}); + +describe('resolveOAuthConfigKey', () => { + const originalOauthBuildType = process.env.OAUTH_BUILD_TYPE; + + afterEach(() => { + if (originalOauthBuildType === undefined) { + delete process.env.OAUTH_BUILD_TYPE; + } else { + process.env.OAUTH_BUILD_TYPE = originalOauthBuildType; + } + }); + + it('returns OAUTH_BUILD_TYPE when set to a valid config key', () => { + jest.resetModules(); + process.env.OAUTH_BUILD_TYPE = BUILD_TYPE.main_prod; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- Jest reload after resetModules; dynamic import needs experimental-vm-modules + const { resolveOAuthConfigKey } = require('./oauthBuildType'); + expect(resolveOAuthConfigKey()).toBe(BUILD_TYPE.main_prod); + }); + + it('ignores OAUTH_BUILD_TYPE when not a key of OAUTH_CONFIG', () => { + jest.resetModules(); + process.env.OAUTH_BUILD_TYPE = 'not_a_real_key'; + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const { resolveOAuthConfigKey } = require('./oauthBuildType'); + const key = resolveOAuthConfigKey(); + expect(key).not.toBe('not_a_real_key'); + expect(key in OAUTH_CONFIG).toBe(true); + }); +}); diff --git a/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.ts b/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.ts new file mode 100644 index 00000000000..9888697283a --- /dev/null +++ b/app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.ts @@ -0,0 +1,56 @@ +import AppConstants from '../../AppConstants'; +import { isQa } from '../../../util/test/utils'; +import { BUILD_TYPE, OAUTH_CONFIG } from './config'; + +/** + * Maps MetaMask build type + dev/QA flags to OAuth config keys. + * @param buildType - e.g. main, qa, flask + * @param isDev - development build + * @param isQaChannel - QA / e2e / exp channel + */ +export function buildTypeMapping( + buildType: string, + isDev: boolean, + isQaChannel: boolean, +): BUILD_TYPE { + if (process.env.DEV_OAUTH_CONFIG === 'true' && isDev) { + return BUILD_TYPE.development; + } + + switch (buildType) { + case 'qa': + return BUILD_TYPE.main_uat; + case 'main': { + if (isQaChannel) return BUILD_TYPE.main_uat; + if (isDev) return BUILD_TYPE.main_dev; + return BUILD_TYPE.main_prod; + } + case 'flask': { + if (isQaChannel) return BUILD_TYPE.flask_uat; + if (isDev) return BUILD_TYPE.flask_dev; + return BUILD_TYPE.flask_prod; + } + default: + return BUILD_TYPE.development; + } +} + +/** + * Resolves which {@link OAUTH_CONFIG} entry applies (env override or build mapping). + */ +export function resolveOAuthConfigKey(): keyof typeof OAUTH_CONFIG { + const fromEnv = process.env.OAUTH_BUILD_TYPE; + if ( + typeof fromEnv === 'string' && + fromEnv.length > 0 && + fromEnv in OAUTH_CONFIG + ) { + return fromEnv as keyof typeof OAUTH_CONFIG; + } + + return buildTypeMapping( + AppConstants.METAMASK_BUILD_TYPE || 'main', + AppConstants.IS_DEV, + isQa, + ); +} diff --git a/app/core/OAuthService/OAuthService.test.ts b/app/core/OAuthService/OAuthService.test.ts index 18580d08698..4008fa22619 100644 --- a/app/core/OAuthService/OAuthService.test.ts +++ b/app/core/OAuthService/OAuthService.test.ts @@ -6,7 +6,7 @@ import { OAuthError, OAuthErrorType } from './error'; import { Web3AuthNetwork } from '@metamask/seedless-onboarding-controller'; import { TraceName, TraceOperation } from '../../util/trace'; import { signOut as acmSignOut } from '@metamask/react-native-acm'; -import { SET_SEEDLESS_ONBOARDING } from '../../actions/onboarding'; +const MOCK_GOOGLE_OAUTH_CLIENT_ID = 'abc.apps.googleusercontent.com'; const MOCK_JWT_TOKEN = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InN3bmFtOTA5QGdtYWlsLmNvbSIsInN1YiI6InN3bmFtOTA5QGdtYWlsLmNvbSIsImlzcyI6Im1ldGFtYXNrIiwiYXVkIjoibWV0YW1hc2siLCJpYXQiOjE3NDUyMDc1NjYsImVhdCI6MTc0NTIwNzg2NiwiZXhwIjoxNzQ1MjA3ODY2fQ.nXRRLB7fglRll7tMzFFCU0u7Pu6EddqEYf_DMyRgOENQ6tJ8OLtVknNf83_5a67kl_YKHFO-0PEjvJviPID6xg'; @@ -123,7 +123,7 @@ const mockGetAuthTokens = jest.fn().mockImplementation(() => ({ const mockCreateLoginHandler = jest.fn().mockImplementation(() => ({ authConnection: AuthConnection.Google, options: { - clientId: 'e2e-mock-google-client-id', + clientId: MOCK_GOOGLE_OAUTH_CLIENT_ID, authServerUrl: 'https://auth.example.com', web3AuthNetwork: 'sapphire_mainnet', }, @@ -221,7 +221,7 @@ describe('OAuth login service', () => { }), ); expect(mockDispatch).toHaveBeenCalledWith({ - type: SET_SEEDLESS_ONBOARDING, + type: 'SET_SEEDLESS_ONBOARDING', clientId: 'clientId', authConnection: AuthConnection.Google, }); @@ -675,7 +675,7 @@ describe('OAuth login service', () => { delete process.env.E2E_MOCK_OAUTH_EMAIL; }); - it('exchanges QA mock tokens and returns mock success without seedless authenticate', async () => { + it('exchanges QA mock tokens, calls seedless authenticate, and dispatches seedless onboarding', async () => { const loginHandler = mockCreateLoginHandler(); const result = await OAuthLoginService.handleOAuthLogin( @@ -700,15 +700,18 @@ describe('OAuth login service', () => { const body = JSON.parse( (fetchSpy.mock.calls[0][1] as RequestInit).body as string, ); - expect(body).toMatchObject({ - email_id: 'newuser+e2e@web3auth.io', - client_id: 'e2e-mock-google-client-id', - login_provider: AuthConnection.Google, - access_type: 'offline', - }); - expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(body.client_id).toBe(MOCK_GOOGLE_OAUTH_CLIENT_ID); + expect(body.login_provider).toBe(AuthConnection.Google); + expect(body.access_type).toBe('offline'); + expect(body.email_id).toMatch(/^[a-f0-9]+\d+\+e2e@web3auth\.io$/); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); expect(mockLoginHandlerResponse).not.toHaveBeenCalled(); expect(mockGetAuthTokens).not.toHaveBeenCalled(); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SEEDLESS_ONBOARDING', + clientId: MOCK_GOOGLE_OAUTH_CLIENT_ID, + authConnection: AuthConnection.Google, + }); }); it('uses E2E_MOCK_OAUTH_EMAIL for email_id when set', async () => { @@ -739,7 +742,7 @@ describe('OAuth login service', () => { expect(mockAuthenticate).not.toHaveBeenCalled(); }); - it('succeeds when QA mock response omits refresh_token', async () => { + it('rejects when QA mock response omits refresh_token (seedless authenticate requires it)', async () => { fetchSpy.mockResolvedValueOnce({ ok: true, status: 200, @@ -757,23 +760,27 @@ describe('OAuth login service', () => { } as Response); const loginHandler = mockCreateLoginHandler(); - const result = await OAuthLoginService.handleOAuthLogin( - loginHandler, - false, + await expectOAuthError( + OAuthLoginService.handleOAuthLogin(loginHandler, false), + OAuthErrorType.LoginError, ); - expect(result.type).toBe('success'); expect(mockAuthenticate).not.toHaveBeenCalled(); }); - it('does not call provider login, getAuthTokens, or seedless authenticate', async () => { + it('does not call provider login or getAuthTokens but does call seedless authenticate', async () => { const loginHandler = mockCreateLoginHandler(); await OAuthLoginService.handleOAuthLogin(loginHandler, false); expect(mockLoginHandlerResponse).not.toHaveBeenCalled(); expect(mockGetAuthTokens).not.toHaveBeenCalled(); - expect(mockAuthenticate).not.toHaveBeenCalled(); + expect(mockAuthenticate).toHaveBeenCalledTimes(1); + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'SET_SEEDLESS_ONBOARDING', + clientId: MOCK_GOOGLE_OAUTH_CLIENT_ID, + authConnection: AuthConnection.Google, + }); }); }); }); diff --git a/app/core/OAuthService/OAuthService.ts b/app/core/OAuthService/OAuthService.ts index 32bf4500b09..2debff7f404 100644 --- a/app/core/OAuthService/OAuthService.ts +++ b/app/core/OAuthService/OAuthService.ts @@ -140,10 +140,6 @@ export class OAuthService { throw new Error('No user id found'); } - if (isE2EMockOAuth()) { - return QAMockOAuthService.mockSeedlessHandleResult(accountName); - } - const authConnectionConfig = getAuthConnectionIdFromClientId({ clientId, authConnection, @@ -214,6 +210,14 @@ export class OAuthService { ); this.#dispatchPostLogin(result); + + ReduxService.store.dispatch( + setSeedlessOnboarding({ + clientId: loginHandler.options.clientId, + authConnection: loginHandler.authConnection, + }), + ); + return result; }; diff --git a/app/core/OAuthService/QAMockOAuthService.test.ts b/app/core/OAuthService/QAMockOAuthService.test.ts index 7e108af1aea..d78be1bf023 100644 --- a/app/core/OAuthService/QAMockOAuthService.test.ts +++ b/app/core/OAuthService/QAMockOAuthService.test.ts @@ -5,8 +5,18 @@ import type { BaseLoginHandler } from './OAuthLoginHandlers/baseHandler'; const MOCK_JWT_TOKEN = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6InN3bmFtOTA5QGdtYWlsLmNvbSIsInN1YiI6InN3bmFtOTA5QGdtYWlsLmNvbSIsImlzcyI6Im1ldGFtYXNrIiwiYXVkIjoibWV0YW1hc2siLCJpYXQiOjE3NDUyMDc1NjYsImVhdCI6MTc0NTIwNzg2NiwiZXhwIjoxNzQ1MjA3ODY2fQ.nXRRLB7fglRll7tMzFFCU0u7Pu6EddqEYf_DMyRgOENQ6tJ8OLtVknNf83_5a67kl_YKHFO-0PEjvJviPID6xg'; +jest.mock('./OAuthLoginHandlers/constants', () => { + const actual = jest.requireActual< + typeof import('./OAuthLoginHandlers/constants') + >('./OAuthLoginHandlers/constants'); + return { + ...actual, + E2E_QA_MOCK_OAUTH_TOKEN_URL: 'https://test.qa.mock/oauth/token', + }; +}); + const mockGetE2EByoaAuthSecret = jest.fn( - () => 'test-byoa-secret', + () => undefined, ); const mockGetE2EMockOAuthEmailForQaMock = jest.fn( () => undefined, @@ -18,23 +28,27 @@ jest.mock('../../util/environment', () => ({ getE2EMockOAuthEmailForQaMock: () => mockGetE2EMockOAuthEmailForQaMock(), })); -jest.mock('./OAuthLoginHandlers/constants', () => { - const actual = jest.requireActual< - typeof import('./OAuthLoginHandlers/constants') - >('./OAuthLoginHandlers/constants'); - return { - ...actual, - E2E_QA_MOCK_OAUTH_TOKEN_URL: 'https://test.qa.mock/oauth/token', - }; -}); +jest.mock('react-native-quick-crypto', () => ({ + __esModule: true, + default: { + randomBytes: jest.fn((size: number) => + Buffer.from(Array.from({ length: size }, (_, i) => (i % 255) + 1)), + ), + }, +})); import { QAMockOAuthService } from './QAMockOAuthService'; +const MOCK_GOOGLE_OAUTH_CLIENT_ID = + process.env.IOS_GOOGLE_CLIENT_ID || + process.env.ANDROID_GOOGLE_SERVER_CLIENT_ID || + 'abc.apps.googleusercontent.com'; + const createStubLoginHandler = (): BaseLoginHandler => ({ authConnection: AuthConnection.Google, options: { - clientId: 'e2e-mock-google-client-id', + clientId: MOCK_GOOGLE_OAUTH_CLIENT_ID, authServerUrl: 'https://auth.example.com', web3AuthNetwork: 'sapphire_mainnet', }, @@ -53,7 +67,7 @@ const createStubLoginHandler = (): BaseLoginHandler => describe('QAMockOAuthService', () => { beforeEach(() => { jest.clearAllMocks(); - mockGetE2EByoaAuthSecret.mockReturnValue('test-byoa-secret'); + mockGetE2EByoaAuthSecret.mockReturnValue(undefined); mockGetE2EMockOAuthEmailForQaMock.mockReturnValue(undefined); }); @@ -101,20 +115,8 @@ describe('QAMockOAuthService', () => { }); }); - describe('mockSeedlessHandleResult', () => { - it('returns success with existingUser false and accountName', () => { - const result = - QAMockOAuthService.mockSeedlessHandleResult('user@example.com'); - - expect(result.type).toBe(OAuthLoginResultType.SUCCESS); - expect(result.existingUser).toBe(false); - expect(result.accountName).toBe('user@example.com'); - }); - }); - describe('exchangeTokens', () => { it('uses default BYOA secret when env secret is unset', async () => { - mockGetE2EByoaAuthSecret.mockReturnValue(undefined); const envelope = { success: true, data: { @@ -148,6 +150,10 @@ describe('QAMockOAuthService', () => { }); it('POSTs QA mock URL and returns data userId and accountName', async () => { + mockGetE2EByoaAuthSecret.mockReturnValue('test-byoa-secret'); + mockGetE2EMockOAuthEmailForQaMock.mockReturnValue( + 'newuser+e2e@web3auth.io', + ); const envelope = { success: true, data: { @@ -185,7 +191,7 @@ describe('QAMockOAuthService', () => { ); expect(body).toMatchObject({ email_id: 'newuser+e2e@web3auth.io', - client_id: 'e2e-mock-google-client-id', + client_id: MOCK_GOOGLE_OAUTH_CLIENT_ID, login_provider: AuthConnection.Google, access_type: 'offline', }); diff --git a/app/core/OAuthService/QAMockOAuthService.ts b/app/core/OAuthService/QAMockOAuthService.ts index c8147aa7cf5..735bd74d780 100644 --- a/app/core/OAuthService/QAMockOAuthService.ts +++ b/app/core/OAuthService/QAMockOAuthService.ts @@ -1,16 +1,12 @@ -import { - getE2EByoaAuthSecret, - getE2EMockOAuthEmailForQaMock, -} from '../../util/environment'; import { E2E_QA_MOCK_OAUTH_TOKEN_URL } from './OAuthLoginHandlers/constants'; import { OAuthError, OAuthErrorType } from './error'; import { BaseLoginHandler } from './OAuthLoginHandlers/baseHandler'; +import { type AuthResponse, type OAuthUserInfo } from './OAuthInterface'; import { - OAuthLoginResultType, - type AuthResponse, - type HandleOAuthLoginResult, - type OAuthUserInfo, -} from './OAuthInterface'; + getE2EByoaAuthSecret, + getE2EMockOAuthEmailForQaMock, +} from '../../util/environment'; +import QuickCrypto from 'react-native-quick-crypto'; export interface QAMockTokenExchangeResult { data: AuthResponse; @@ -20,6 +16,11 @@ export interface QAMockTokenExchangeResult { const DEFAULT_E2E_BYOA_AUTH_SECRET = '6SMBaAx6*TG8AEQ+7Ap#zEUAIZ42'; +const generateUniqueE2EEmail = (): string => { + const rand = QuickCrypto.randomBytes(4).toString('hex').slice(0, 8); + return `${rand}${Date.now()}+e2e@web3auth.io`; +}; + export class QAMockOAuthService { static async exchangeTokens( loginHandler: BaseLoginHandler, @@ -27,9 +28,8 @@ export class QAMockOAuthService { ): Promise { const byoaSecret = getE2EByoaAuthSecret() ?? DEFAULT_E2E_BYOA_AUTH_SECRET; - const e2eEmail = `${loginHandler.authConnection}.newuser+e2e@web3auth.io`; const emailForMock = - getE2EMockOAuthEmailForQaMock() ?? 'newuser+e2e@web3auth.io'; + getE2EMockOAuthEmailForQaMock() ?? generateUniqueE2EEmail(); const response = await fetchImpl(E2E_QA_MOCK_OAUTH_TOKEN_URL, { method: 'POST', @@ -58,22 +58,12 @@ export class QAMockOAuthService { const jwtPayload = JSON.parse( loginHandler.decodeIdToken(data.id_token), ) as Partial; - const userId = jwtPayload.sub ?? `e2e-user-${e2eEmail}`; - const accountName = jwtPayload.email ?? e2eEmail; + const userId = jwtPayload.sub ?? `e2e-user-${emailForMock}`; + const accountName = jwtPayload.email ?? emailForMock; return { data, userId, accountName }; } - static mockSeedlessHandleResult( - accountName?: string, - ): HandleOAuthLoginResult { - return { - type: OAuthLoginResultType.SUCCESS, - existingUser: false, - accountName, - }; - } - static parseAuthServiceResponse(raw: unknown): AuthResponse { if (raw === null || typeof raw !== 'object') { throw new OAuthError( diff --git a/app/util/environment.ts b/app/util/environment.ts index 11c1df95755..d95f6953288 100644 --- a/app/util/environment.ts +++ b/app/util/environment.ts @@ -9,12 +9,12 @@ export const isProduction = (): boolean => export const isE2EMockOAuth = (): boolean => process.env.E2E_MOCK_OAUTH === 'true'; -export const getE2EByoaAuthSecret = (): string | undefined => { +export function getE2EByoaAuthSecret(): string | undefined { const secret = process.env.E2E_BYOA_AUTH_SECRET; return typeof secret === 'string' && secret.length > 0 ? secret : undefined; -}; +} -export const getE2EMockOAuthEmailForQaMock = (): string | undefined => { +export function getE2EMockOAuthEmailForQaMock(): string | undefined { const email = process.env.E2E_MOCK_OAUTH_EMAIL; return typeof email === 'string' && email.length > 0 ? email : undefined; -}; +} diff --git a/app/util/test/testSetup.js b/app/util/test/testSetup.js index 06ca789fb08..efb2e73814a 100644 --- a/app/util/test/testSetup.js +++ b/app/util/test/testSetup.js @@ -23,52 +23,58 @@ jest.mock('expo/fetch', () => { }; }); -jest.mock('react-native-quick-crypto', () => ({ - getRandomValues: jest.fn((array) => { - for (let i = 0; i < array.length; i++) { - array[i] = Math.floor(Math.random() * 256); - } - return array; - }), - subtle: { - importKey: jest.fn((format, keyData, algorithm, extractable, keyUsages) => { - return Promise.resolve({ - format, - keyData, - algorithm, - extractable, - keyUsages, - }); - }), - deriveBits: jest.fn((algorithm, baseKey, length) => { - const derivedBits = new Uint8Array(length); - for (let i = 0; i < length; i++) { - derivedBits[i] = Math.floor(Math.random() * 256); +jest.mock('react-native-quick-crypto', () => { + const { randomBytes: nodeRandomBytes } = require('crypto'); + return { + getRandomValues: jest.fn((array) => { + for (let i = 0; i < array.length; i++) { + array[i] = Math.floor(Math.random() * 256); } - return Promise.resolve(derivedBits); - }), - exportKey: jest.fn((format, key) => { - return Promise.resolve(new Uint8Array([1, 2, 3, 4])); + return array; }), - encrypt: jest.fn((algorithm, key, data) => { - return Promise.resolve( - new Uint8Array([ - 123, 34, 116, 101, 115, 116, 34, 58, 34, 100, 97, 116, 97, 34, 125, - ]), - ); - }), - decrypt: jest.fn((algorithm, key, data) => { - return Promise.resolve( - new Uint8Array([ - 123, 34, 116, 101, 115, 116, 34, 58, 34, 100, 97, 116, 97, 34, 125, - ]), - ); - }), - }, - randomUUID: jest.fn( - () => 'mock-uuid-' + Math.random().toString(36).slice(2, 11), - ), -})); + randomBytes: jest.fn((size) => nodeRandomBytes(size)), + subtle: { + importKey: jest.fn( + (format, keyData, algorithm, extractable, keyUsages) => { + return Promise.resolve({ + format, + keyData, + algorithm, + extractable, + keyUsages, + }); + }, + ), + deriveBits: jest.fn((algorithm, baseKey, length) => { + const derivedBits = new Uint8Array(length); + for (let i = 0; i < length; i++) { + derivedBits[i] = Math.floor(Math.random() * 256); + } + return Promise.resolve(derivedBits); + }), + exportKey: jest.fn((format, key) => { + return Promise.resolve(new Uint8Array([1, 2, 3, 4])); + }), + encrypt: jest.fn((algorithm, key, data) => { + return Promise.resolve( + new Uint8Array([ + 123, 34, 116, 101, 115, 116, 34, 58, 34, 100, 97, 116, 97, 34, 125, + ]), + ); + }), + decrypt: jest.fn((algorithm, key, data) => { + return Promise.resolve( + new Uint8Array([ + 123, 34, 116, 101, 115, 116, 34, 58, 34, 100, 97, 116, 97, 34, 125, + ]), + ); + }), + }, + randomUUID: jest.fn( + () => 'mock-uuid-' + Math.random().toString(36).slice(2, 11), + ), + }; +}); // Create a persistent mock function that survives Jest teardown const mockBatchedUpdates = jest.fn((fn) => { diff --git a/babel.config.tests.js b/babel.config.tests.js index 0a2706e97d4..671e1da6a3d 100644 --- a/babel.config.tests.js +++ b/babel.config.tests.js @@ -55,6 +55,8 @@ const newOverrides = [ 'app/components/UI/Card/util/mapBaanxApiUrl.test.ts', 'app/core/Engine/controllers/card-controller/services/baanx-config.ts', 'app/core/Engine/controllers/card-controller/services/baanx-config.test.ts', + 'app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.ts', + 'app/core/OAuthService/OAuthLoginHandlers/oauthBuildType.test.ts', 'app/components/UI/Predict/providers/polymarket/protocol/definitions.ts', 'app/components/UI/Predict/providers/polymarket/protocol/definitions.test.ts', 'app/store/migrations/**', diff --git a/metro.config.js b/metro.config.js index 2cdcde3aa83..b5ece123f24 100644 --- a/metro.config.js +++ b/metro.config.js @@ -171,7 +171,10 @@ module.exports = function (baseConfig) { }; } } - if (e2eAllowsSeedlessOAuthMetroMocks) { + // Mock the seedless-onboarding-controller only for E2E smoke tests. + // Performance tests (E2E_MOCK_OAUTH only) use the real controller + // so TOPRF authentication runs against live nodes. + if (isE2E) { if ( moduleName.endsWith( 'controllers/seedless-onboarding-controller', @@ -190,6 +193,8 @@ module.exports = function (baseConfig) { ), }; } + } + if (e2eAllowsSeedlessOAuthMetroMocks) { // Skips native Google/Apple UI; tokens still hit auth server (see module mock). if ( moduleName.endsWith('OAuthService/OAuthLoginHandlers') || diff --git a/tests/api-mocking/seedless-onboarding/OAuthMockttpService.ts b/tests/api-mocking/seedless-onboarding/OAuthMockttpService.ts index e2d8c589b38..3a1508df45b 100644 --- a/tests/api-mocking/seedless-onboarding/OAuthMockttpService.ts +++ b/tests/api-mocking/seedless-onboarding/OAuthMockttpService.ts @@ -18,6 +18,25 @@ import { import { OAuthMockttpServiceOptions, SecretType } from './types'; +const DEFAULT_MOCK_APPLE_OAUTH_CLIENT_ID = 'io.metamask.appleloginclient.dev'; +const DEFAULT_MOCK_GOOGLE_OAUTH_CLIENT_ID = + '615965109465-i8oeh9kuvl1n6lk1ffkobpvth27bmi41.apps.googleusercontent.com'; + +function defaultMockOAuthClientIdForTokenRequest( + loginProvider: E2ELoginProvider, +): string { + if (loginProvider === E2ELoginProvider.APPLE) { + return ( + process.env.ANDROID_APPLE_CLIENT_ID || DEFAULT_MOCK_APPLE_OAUTH_CLIENT_ID + ); + } + return ( + process.env.IOS_GOOGLE_CLIENT_ID || + process.env.ANDROID_GOOGLE_SERVER_CLIENT_ID || + DEFAULT_MOCK_GOOGLE_OAUTH_CLIENT_ID + ); +} + /** * Configuration for E2E OAuth mock */ @@ -265,7 +284,11 @@ export class OAuthMockttpService { const mockRequestBody = { email_id: emailForMock, - client_id: body.client_id || 'e2e-mock-client-id', + client_id: + body.client_id || + defaultMockOAuthClientIdForTokenRequest( + this.config.loginProvider, + ), login_provider: this.config.loginProvider, access_type: 'offline', }; @@ -289,12 +312,17 @@ export class OAuthMockttpService { let tokens; if (!response.ok) { + const errorText = await response.text().catch(() => ''); console.warn( - `[E2E] Backend QA mock returned ${response.status}, using fallback mock tokens`, + `[E2E] Backend QA mock returned ${response.status}: ${errorText}, using fallback mock tokens`, ); tokens = this.generateMockAuthResponse(); } else { const rawResponse = await response.json(); + console.log( + '[E2E] QA mock raw response:', + JSON.stringify(rawResponse, null, 2), + ); const fallbackTokens = this.generateMockAuthResponse(); if (rawResponse.data?.tokens) { const qaMockTokens = rawResponse.data.tokens; @@ -361,7 +389,11 @@ export class OAuthMockttpService { const mockRequestBody = { email_id: emailForMock, - client_id: body.client_id || 'e2e-mock-client-id', + client_id: + body.client_id || + defaultMockOAuthClientIdForTokenRequest( + this.config.loginProvider, + ), login_provider: this.config.loginProvider, }; @@ -433,7 +465,11 @@ export class OAuthMockttpService { const mockRequestBody = { email_id: emailForMock, - client_id: body.client_id || 'e2e-mock-client-id', + client_id: + body.client_id || + defaultMockOAuthClientIdForTokenRequest( + this.config.loginProvider, + ), login_provider: this.config.loginProvider, refresh_token: body.refresh_token, }; @@ -624,7 +660,15 @@ export class OAuthMockttpService { console.log( `[E2E MockServer] SSS Node ${nodeIndex} request: ${body.method}`, ); + console.log( + `[E2E MockServer] SSS Node ${nodeIndex} request body:`, + JSON.stringify(body, null, 2), + ); const response = this.handleToprfRequest(body, nodeIndex); + console.log( + `[E2E MockServer] SSS Node ${nodeIndex} response:`, + JSON.stringify(response, null, 2), + ); return { statusCode: 200, @@ -850,7 +894,7 @@ export class OAuthMockttpService { const now = Math.floor(Date.now() / 1000); const exp = now + 3600; // 1 hour expiry - // Mock ID token payload + // Mock ID token payload (client_id prefers env OAuth client ids, same as QA proxy body) const idTokenPayload = { iss: 'https://auth-service.uat-api.cx.metamask.io', sub: `e2e-user-${this.config.email}`, @@ -859,6 +903,10 @@ export class OAuthMockttpService { email_verified: true, iat: now, exp, + client_id: defaultMockOAuthClientIdForTokenRequest( + this.config.loginProvider, + ), + env: 'uat', verifier: this.config.loginProvider, verifier_id: this.config.email, // Include aggregateVerifier for TOPRF diff --git a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts index 4e2a6eddca0..bd26e7c3fd8 100644 --- a/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts +++ b/tests/module-mocking/oauth/OAuthLoginHandlers/index.ts @@ -15,6 +15,25 @@ import { } from '../../../../app/core/OAuthService/OAuthLoginHandlers/constants'; import type { BaseHandlerOptions } from '../../../../app/core/OAuthService/OAuthLoginHandlers/baseHandler'; +const MOCK_GOOGLE_OAUTH_CLIENT_ID_IOS = + process.env.MAIN_IOS_GOOGLE_CLIENT_ID_UAT; +const MOCK_GOOGLE_OAUTH_CLIENT_ID_ANDROID = + process.env.MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT; + +function getMockGoogleOAuthClientId(): string { + const clientId = + Platform.OS === 'ios' + ? MOCK_GOOGLE_OAUTH_CLIENT_ID_IOS + : MOCK_GOOGLE_OAUTH_CLIENT_ID_ANDROID; + if (!clientId) { + throw new Error( + `[E2E Mock] Missing Google OAuth UAT client ID env var for platform "${Platform.OS}". ` + + 'Ensure MAIN_IOS_GOOGLE_CLIENT_ID_UAT or MAIN_ANDROID_GOOGLE_CLIENT_ID_UAT is set.', + ); + } + return clientId; +} + /** * Login result type */ @@ -247,13 +266,19 @@ export function createLoginHandler( switch (provider) { case 'google': return new MockGoogleLoginHandler({ - clientId: 'e2e-mock-google-client-id', + clientId: getMockGoogleOAuthClientId(), redirectUri: 'metamask://e2e', }); - case 'apple': - return new MockAppleLoginHandler({ - clientId: 'e2e-mock-apple-client-id', - }); + case 'apple': { + const appleClientId = process.env.MAIN_ANDROID_APPLE_CLIENT_ID_UAT; + if (!appleClientId) { + throw new Error( + '[E2E Mock] Missing Apple OAuth UAT client ID. ' + + 'Ensure MAIN_ANDROID_APPLE_CLIENT_ID_UAT is set.', + ); + } + return new MockAppleLoginHandler({ clientId: appleClientId }); + } default: throw new Error(`[E2E Mock] Unsupported provider: ${provider}`); } diff --git a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts index 762e5dcd47f..208d2342ba5 100644 --- a/tests/performance/onboarding/seedless-apple-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-apple-onboarding.spec.ts @@ -10,7 +10,6 @@ import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; import CreatePasswordView from '../../page-objects/Onboarding/CreatePasswordView'; import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSuccessView'; import PredictModalView from '../../page-objects/Predict/PredictModalView'; -import WalletView from '../../page-objects/wallet/WalletView'; import LoginView from '../../page-objects/wallet/LoginView'; const waitForFirstSuccessful = async (promises: Promise[]): Promise => @@ -60,11 +59,6 @@ test.describe(PerformanceOnboarding, () => { { ios: 2500, android: 3100 }, currentDeviceDetails.platform, ); - const timer6 = new TimerHelper( - 'Apple: Dismiss feature sheet → wallet main screen visible', - { ios: 30000, android: 30000 }, - currentDeviceDetails.platform, - ); const password = getPasswordForScenario('onboarding') ?? ''; @@ -138,16 +132,8 @@ test.describe(PerformanceOnboarding, () => { }); await dismisspredictionsModalPlaywright(); - await timer6.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), - { - description: 'Wallet main screen should be visible', - }, - ); - }); - const timers = [timer1, timer2, timer4, timer5, timer6]; + const timers = [timer1, timer2, timer4, timer5]; if (currentDeviceDetails.platform === 'ios') { timers.splice(2, 0, timer3); } @@ -161,16 +147,7 @@ test.describe(PerformanceOnboarding, () => { await LoginView.enterPassword(password); await LoginView.tapLoginButton(); - await timer4.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), - { - description: 'Wallet main screen should be visible', - }, - ); - }); - - performanceTracker.addTimers(timer1, timer2, timer3, timer4); + performanceTracker.addTimers(timer1, timer2, timer3); } }, ); diff --git a/tests/performance/onboarding/seedless-google-onboarding.spec.ts b/tests/performance/onboarding/seedless-google-onboarding.spec.ts index 809471d5faf..b6f02809fc5 100644 --- a/tests/performance/onboarding/seedless-google-onboarding.spec.ts +++ b/tests/performance/onboarding/seedless-google-onboarding.spec.ts @@ -10,7 +10,6 @@ import SocialLoginView from '../../page-objects/Onboarding/SocialLoginView'; import CreatePasswordView from '../../page-objects/Onboarding/CreatePasswordView'; import OnboardingSuccessView from '../../page-objects/Onboarding/OnboardingSuccessView'; import PredictModalView from '../../page-objects/Predict/PredictModalView'; -import WalletView from '../../page-objects/wallet/WalletView'; import LoginView from '../../page-objects/wallet/LoginView'; const waitForFirstSuccessful = async (promises: Promise[]): Promise => @@ -60,11 +59,6 @@ test.describe(PerformanceOnboarding, () => { { ios: 2500, android: 3100 }, currentDeviceDetails.platform, ); - const timer6 = new TimerHelper( - 'Google: Dismiss feature sheet → wallet main screen visible', - { ios: 30000, android: 30000 }, - currentDeviceDetails.platform, - ); const password = getPasswordForScenario('onboarding') ?? ''; @@ -138,16 +132,8 @@ test.describe(PerformanceOnboarding, () => { }); await dismisspredictionsModalPlaywright(); - await timer6.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), - { - description: 'Wallet main screen should be visible', - }, - ); - }); - const timers = [timer1, timer2, timer4, timer5, timer6]; + const timers = [timer1, timer2, timer4, timer5]; if (currentDeviceDetails.platform === 'ios') { timers.splice(2, 0, timer3); } @@ -161,16 +147,7 @@ test.describe(PerformanceOnboarding, () => { await LoginView.enterPassword(password); await LoginView.tapLoginButton(); - await timer4.measure(async () => { - await PlaywrightAssertions.expectElementToBeVisible( - asPlaywrightElement(WalletView.container), - { - description: 'Wallet main screen should be visible', - }, - ); - }); - - performanceTracker.addTimers(timer1, timer2, timer3, timer4); + performanceTracker.addTimers(timer1, timer2, timer3); } }, );