import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_auth/sxchnge_auth.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; import 'package:sxchnge_app/main.dart'; import 'package:sxchnge_app/providers/locale.dart'; import 'destination_helpers.dart'; Future tapFactor(WidgetTester t, String key) async { final finder = find.byKey(ValueKey(key)); await t.ensureVisible(finder); await t.tap(finder); await t.pumpAndSettle(); } Future openFactor( WidgetTester t, { String path = '/settings', bool enabled = false, String locale = 'en', FactorMethod method = FactorMethod.email, DummyAnalyticsService? analytics, }) async { t.platformDispatcher.defaultRouteNameTestValue = path; addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue); final service = DummyTwoFactorService( enabled: enabled, delay: Duration.zero, method: method, ); final wallet = DummyWalletService( DummyIdentityService(delay: Duration.zero), factors: service, initialLocale: locale, delay: Duration.zero, ); await seedDestination(wallet.destinations); await t.pumpWidget( SxchngeApp( walletService: wallet, twoFactorService: service, analyticsService: analytics, localeProvider: LocaleProvider(initialLocale: Locale(locale)), ), ); await t.pumpAndSettle(); addTearDown(() => t.pumpWidget(const SizedBox.shrink())); return service; } Future signIn(WidgetTester t) async { await t.enterText(find.byKey(const ValueKey('email')), 'person@example.com'); await t.enterText(find.byKey(const ValueKey('password')), 'example-password'); await tapFactor(t, 'submit'); } void main() { testWidgets('settings enable and disable only after verified code', ( t, ) async { final service = await openFactor(t); expect(find.text('Off'), findsOneWidget); await tapFactor(t, 'two-factor-setting'); expect((await t.runAsync(service.fetch))!.enabled, false); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000'); await tapFactor(t, 'two-factor-confirm'); expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget); expect((await t.runAsync(service.fetch))!.enabled, false); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await tapFactor(t, 'two-factor-confirm'); expect(find.text('On'), findsOneWidget); expect((await t.runAsync(service.fetch))!.enabled, true); await tapFactor(t, 'two-factor-setting'); expect(find.textContaining('turn off two-factor'), findsOneWidget); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await tapFactor(t, 'two-factor-confirm'); expect(find.text('Off'), findsOneWidget); expect((await t.runAsync(service.fetch))!.enabled, false); }); for (final entry in { FactorError.expired: 'This verification has expired.', FactorError.locked: 'Too many incorrect attempts.', FactorError.unavailable: 'Could not complete verification.', }.entries) { testWidgets( '${entry.key.name} is recoverable without enabling protection', (t) async { final service = await openFactor(t); await tapFactor(t, 'two-factor-setting'); service.nextError = entry.key; await t.enterText( find.byKey(const ValueKey('two-factor-code')), '123456', ); await tapFactor(t, 'two-factor-confirm'); expect(find.textContaining(entry.value), findsOneWidget); await tapFactor(t, 'two-factor-cancel'); expect(find.text('Off'), findsOneWidget); expect((await t.runAsync(service.fetch))!.enabled, false); }, ); } testWidgets('challenge creation failure is measured and can retry', ( t, ) async { final analytics = DummyAnalyticsService(); final service = await openFactor(t, analytics: analytics); service.nextError = FactorError.unavailable; await tapFactor(t, 'two-factor-setting'); expect( find.textContaining('Could not complete verification.'), findsOneWidget, ); expect( analytics.events.any( (e) => e.step == JourneyStep.factorChallenge && e.failure == JourneyFailure.unavailable, ), true, ); expect(analytics.events.where((e) => e.terminal), isEmpty); await tapFactor(t, 'two-factor-setting'); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); await tapFactor(t, 'two-factor-cancel'); }); testWidgets('cancel enrollment leaves setting unchanged', (t) async { final service = await openFactor(t); await tapFactor(t, 'two-factor-setting'); await tapFactor(t, 'two-factor-cancel'); expect(find.text('Off'), findsOneWidget); expect((await t.runAsync(service.fetch))!.enabled, false); }); testWidgets('login is not terminal until factor and completion succeed', ( t, ) async { final analytics = DummyAnalyticsService(); await openFactor(t, path: '/', enabled: true, analytics: analytics); await signIn(t); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect( t.testTextInput.log, isNot( contains( isMethodCall('TextInput.finishAutofillContext', arguments: true), ), ), ); expect( analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal), isEmpty, ); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000'); await tapFactor(t, 'two-factor-confirm'); expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget); expect( t.testTextInput.log, isNot( contains( isMethodCall('TextInput.finishAutofillContext', arguments: true), ), ), ); await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456'); await tapFactor(t, 'two-factor-confirm'); expect(find.byKey(const ValueKey('account-eur')), findsOneWidget); expect( t.testTextInput.log, contains( isMethodCall('TextInput.finishAutofillContext', arguments: true), ), ); final login = analytics.events .where((e) => e.kind == JourneyKind.login) .toList(); expect(login.where((e) => e.terminal).length, 1); expect(login.map((e) => e.journeyId).toSet().length, 1); expect( login.map((e) => e.properties).toString(), isNot(contains('123456')), ); }); testWidgets('cancel login factor returns without a successful session', ( t, ) async { await openFactor(t, path: '/', enabled: true); await signIn(t); await tapFactor(t, 'two-factor-cancel'); expect(find.byKey(const ValueKey('email')), findsOneWidget); expect( t.testTextInput.log, contains( isMethodCall('TextInput.finishAutofillContext', arguments: false), ), ); expect( t.testTextInput.log, isNot( contains( isMethodCall('TextInput.finishAutofillContext', arguments: true), ), ), ); expect(find.byKey(const ValueKey('preview-result')), findsNothing); expect( t .widget(find.byKey(const ValueKey('password'))) .controller! .text, isEmpty, ); }); for (final kind in ['top-up', 'exchange', 'payout']) { testWidgets( '$kind confirms specific transaction and cancel preserves review', (t) async { await openFactor(t, path: '/$kind', enabled: true); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); if (kind == 'payout') { await selectPayout(t); } await tapFactor(t, 'transfer-review'); await tapFactor(t, 'transfer-confirm'); expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget); expect(find.textContaining('No money moves'), findsOneWidget); expect(find.text('Fee'), findsOneWidget); await tapFactor(t, 'two-factor-cancel'); expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget); expect(find.byKey(const ValueKey('operation-pending')), findsNothing); }, ); } testWidgets( 'money moves after verification and confirmation, without another submit', (t) async { await openFactor(t, path: '/exchange', enabled: true); await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10'); await tapFactor(t, 'transfer-review'); await tapFactor(t, 'transfer-confirm'); await t.enterText( find.byKey(const ValueKey('two-factor-code')), '123456', ); await tapFactor(t, 'two-factor-confirm'); expect(find.byKey(const ValueKey('operation-pending')), findsOneWidget); expect(find.textContaining('amount and fee have left'), findsOneWidget); }, ); testWidgets('authenticator uses SXCHNGE page without email or resend UI', ( t, ) async { await openFactor( t, path: '/', enabled: true, method: FactorMethod.authenticator, ); await signIn(t); expect(find.textContaining('authenticator app'), findsOneWidget); expect(find.byKey(const ValueKey('two-factor-resend')), findsNothing); expect(find.text('a•••@example.com'), findsNothing); }); testWidgets('Russian factor screen fits small viewport and enlarged text', ( t, ) async { t.view.physicalSize = const Size(320, 1100); t.view.devicePixelRatio = 1; t.platformDispatcher.textScaleFactorTestValue = 1.6; addTearDown(t.view.resetPhysicalSize); addTearDown(t.view.resetDevicePixelRatio); addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue); await openFactor(t, locale: 'ru'); await tapFactor(t, 'two-factor-setting'); expect(find.text('Ещё одна проверка.'), findsOneWidget); expect(t.takeException(), null); await tapFactor(t, 'two-factor-cancel'); }); testWidgets('missing challenge deep link returns to login', (t) async { await openFactor(t, path: '/two-factor'); expect(find.byKey(const ValueKey('email')), findsOneWidget); expect(find.byKey(const ValueKey('two-factor-code')), findsNothing); }); }