From 69228ae48282b16fb5ed95222c8e1181e639a873 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Sun, 11 Sep 2022 11:05:00 +0200 Subject: [PATCH] oath account, andr. settings, andr. beta dlg tests --- .../android/beta_welcome_dialog_test.dart | 39 ++ integration_test/android/constants.dart | 22 ++ integration_test/android/test_driver.dart | 11 + integration_test/android/util.dart | 29 ++ integration_test/oath_test.dart | 345 ++++++++++++++++-- integration_test/test_util.dart | 16 +- .../qr_scanner/qr_scanner_ui_view.dart | 1 + lib/android/views/android_settings_page.dart | 21 +- lib/android/views/beta_dialog.dart | 9 +- lib/oath/views/delete_account_dialog.dart | 1 + lib/oath/views/rename_account_dialog.dart | 14 + test/android_settings_page_test.dart | 177 +++++++++ 12 files changed, 651 insertions(+), 34 deletions(-) create mode 100644 integration_test/android/beta_welcome_dialog_test.dart create mode 100644 integration_test/android/constants.dart create mode 100644 integration_test/android/test_driver.dart create mode 100644 integration_test/android/util.dart create mode 100644 test/android_settings_page_test.dart diff --git a/integration_test/android/beta_welcome_dialog_test.dart b/integration_test/android/beta_welcome_dialog_test.dart new file mode 100644 index 00000000..f2e39049 --- /dev/null +++ b/integration_test/android/beta_welcome_dialog_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test_util.dart'; +import 'constants.dart'; + +void main() { + final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + + group('Beta welcome dialog', () { + testWidgets('startup', (WidgetTester tester) async { + await tester.startUp({ + 'dlg.beta.enabled': false, + 'delay.startup': 5, + }); + }); + + testWidgets('shows welcome screen', (WidgetTester tester) async { + await tester.startUp({ + 'dlg.beta.enabled': true, + }); + expect(find.byKey(betaDialogKey), findsOneWidget); + }); + + testWidgets('does not show welcome dialog', (WidgetTester tester) async { + await tester.startUp(); + expect(find.byKey(betaDialogKey), findsNothing); + }); + + testWidgets('updates preferences', (WidgetTester tester) async { + await tester.startUp({'dlg.beta.enabled': true}); + var prefs = await SharedPreferences.getInstance(); + await tester.tap(find.byKey(gotItBtn)); + await expectLater(prefs.getBool(betaDialogPrefName), equals(false)); + }); + }); +} diff --git a/integration_test/android/constants.dart b/integration_test/android/constants.dart new file mode 100644 index 00000000..d0a07892 --- /dev/null +++ b/integration_test/android/constants.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +// widget key names +const betaDialogKey = Key('android.beta.dialog'); +const gotItBtn = Key('android.beta.dialog.btn.got_it'); + +const settingsOnNfcTapOptionKey = Key('android.settings.option.on_nfc_tap'); +const settingsOnNfcTapLaunch = Key('android.settings.on_nfc_tap.launch'); +const settingsOnNfcTapCopy = Key('android.settings.on_nfc_tap.copy'); +const settingsOnNfcTapBoth = Key('android.settings.on_nfc_tap.both'); +const settingsKeyboardLayoutOptionKey = Key('android.settings.option.keyboard_layout'); +const settingsKeyboardLayoutUS = Key('android.settings.keyboard_layout.US'); +const settingsKeyboardLayoutDE = Key('android.settings.keyboard_layout.DE'); +const settingsKeyboardLayoutDECH = Key('android.settings.keyboard_layout.DE-CH'); +const settingsBypassTouchKey = Key('android.settings.bypass_touch'); + +// shared preferences keys +const betaDialogPrefName = 'prefBetaDialogShouldBeShown'; +const prefNfcOpenApp = 'prefNfcOpenApp'; +const prefNfcBypassTouch = 'prefNfcBypassTouch'; +const prefNfcCopyOtp = 'prefNfcCopyOtp'; +const prefClipKbdLayout = 'prefClipKbdLayout'; diff --git a/integration_test/android/test_driver.dart b/integration_test/android/test_driver.dart new file mode 100644 index 00000000..a4342c20 --- /dev/null +++ b/integration_test/android/test_driver.dart @@ -0,0 +1,11 @@ +import 'dart:io'; +import 'dart:async'; + +import 'package:integration_test/integration_test_driver_extended.dart'; + +Future main() async { + await Process.run('adb' , ['shell' ,'pm', 'grant', 'com.yubico.yubioath', 'android.permission.CAMERA']); + await Process.run('adb' , ['shell' ,'pm', 'grant', 'com.yubico.yubioath', 'android.permission.WRITE_EXTERNAL_STORAGE']); + + await integrationDriver(); +} \ No newline at end of file diff --git a/integration_test/android/util.dart b/integration_test/android/util.dart new file mode 100644 index 00000000..d7ef41bd --- /dev/null +++ b/integration_test/android/util.dart @@ -0,0 +1,29 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../test_util.dart'; +import 'constants.dart'; + +class AndroidTestUtils { + static void setShowBetaDialogPref(bool value) async { + SharedPreferences.setMockInitialValues({betaDialogPrefName: value}); + } + + static Future startUp(WidgetTester tester, + [Map? startUpParams]) async { + // on Android disable Beta welcome dialog + // we need to do it before we pump the app + var betaDlgEnabled = startUpParams?['dlg.beta.enabled'] ?? false; + setShowBetaDialogPref(betaDlgEnabled); + + await tester.pumpWidget( + await getAuthenticatorApp(), const Duration(milliseconds: 500)); + + var startupDelay = startUpParams?['delay.startup'] ?? 0; + if (startupDelay != 0) { + tester.printToConsole('Connect YubiKey and approve USB Connection'); + await tester.pump(Duration(seconds: startupDelay)); + tester.printToConsole('Assuming YubiKey connected'); + } + } +} diff --git a/integration_test/oath_test.dart b/integration_test/oath_test.dart index af706c23..a212ed53 100644 --- a/integration_test/oath_test.dart +++ b/integration_test/oath_test.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/oath/views/account_list.dart'; +import 'package:yubico_authenticator/oath/views/account_view.dart'; import 'package:yubico_authenticator/oath/views/oath_screen.dart'; import 'test_util.dart'; @@ -28,20 +30,255 @@ String generateSecret(int index) { return List.generate(16, (_) => base32(index)).toString(); } +class OathDeviceMenu { + static const addAccountKey = Key('add oath account'); + static const setManagePasswordKey = Key('set or manage oath password'); + static const resetKey = Key('reset oath app'); +} + +const qrScannerEnterManuallyKey = Key('android.qr_scanner.btn.enter_manually'); +const deleteAccountBtnKey = Key('oath.dlg.delete_account.btn.delete'); +const renameAccountBtnSaveKey = Key('oath.dlg.rename_account.btn.save'); +const renameAccountEditIssuerKey = Key('oath.dlg.rename_account.edit.issuer'); +const renameAccountEditNameKey = Key('oath.dlg.rename_account.edit.name'); + +const shortestWaitMs = 10; +const shortWaitMs = 50; +const longWaitMs = 200; +const veryLongWaitS = 10; // seconds + +class Account { + final String? issuer; + final String name; + final String secret; + + const Account({ + this.issuer, + this.name = '', + this.secret = 'abcdefghabcdefgh', + }); + + @override + String toString() => '$issuer/$name'; +} + extension OathHelper on WidgetTester { /// Opens the device menu and taps the "Add account" menu item + + Future shortestWait() async { + testLog(false, 'shortestWait ${shortestWaitMs}ms'); + await pump(const Duration(milliseconds: shortestWaitMs)); + } + + Future shortWait() async { + testLog(false, 'shortWait ${shortWaitMs}ms'); + await pump(const Duration(milliseconds: shortWaitMs)); + } + + Future longWait() async { + testLog(false, 'longWait ${longWaitMs}ms'); + await pump(const Duration(milliseconds: longWaitMs)); + } + + Future veryLongWait() async { + testLog(false, 'veryLongWait ${veryLongWaitS}s'); + await pump(const Duration(seconds: veryLongWaitS)); + } + Future tapAddAccount() async { await tapDeviceButton(); - await tap(find.byKey(const Key('add oath account'))); - await pump(const Duration(milliseconds: 500)); + await tap(find.byKey(OathDeviceMenu.addAccountKey).hitTestable()); + await longWait(); } /// Opens the device menu and taps the "Set/Manage password" menu item Future tapSetOrManagePassword() async { - await pump(const Duration(milliseconds: 300)); + await shortWait(); await tapDeviceButton(); - await tap(find.byKey(const Key('set or manage oath password'))); - await pump(const Duration(milliseconds: 500)); + await tap(find.byKey(OathDeviceMenu.setManagePasswordKey)); + await longWait(); + } + + Future addAccount(Account a, {bool quiet = true}) async { + var accountView = await findAccount(a); + if (accountView != null) { + testLog(quiet, 'Account already exists: $a'); + return; + } + + await tapAddAccount(); + + if (isAndroid) { + // on android a QR Scanner starts + // we want to do a manual addition + var manualEntryBtn = find.byKey(qrScannerEnterManuallyKey).hitTestable(); + if (manualEntryBtn.evaluate().isEmpty) { + printToConsole('Allow camera permission'); + await pump(const Duration(seconds: 2)); + manualEntryBtn = find.byKey(qrScannerEnterManuallyKey).hitTestable(); + } + + await tap(manualEntryBtn); + await longWait(); + } + + var issuerText = find.byKey(const Key('issuer')).hitTestable(); + await tap(issuerText); + await enterText(issuerText, a.issuer ?? ''); + await shortWait(); + var nameText = find.byKey(const Key('name')).hitTestable(); + await tap(nameText); + await enterText(nameText, a.name); + await shortWait(); + var secretText = find.byKey(const Key('secret')).hitTestable(); + await tap(secretText); + await enterText(secretText, a.secret); + await shortWait(); + + await tap(find.byKey(const Key('save_btn'))); + + await longWait(); + + accountView = await findAccount(a); + expect(accountView, isNotNull); + if (accountView != null) { + testLog(quiet, 'Added account $a'); + } + } + + Future getAccountList() async { + var accountList = find.byType(AccountList).hitTestable(); + expect(accountList, findsOneWidget); + return accountList.evaluate().single.widget as AccountList; + } + + Future findAccount(Account a, {bool quiet = true}) async { + var accountList = find.byType(AccountList); + expect(accountList, findsOneWidget); + + // find an AccountView with issuer/name in the account list + var matchingAccounts = find.descendant( + of: accountList, + matching: find.byWidgetPredicate( + (widget) => + widget is AccountView && + widget.credential.name == a.name && + widget.credential.issuer == a.issuer, + skipOffstage: false)); + + matchingAccounts.evaluate().forEach((element) { + var widget = element.widget; + if (widget is AccountView) { + testLog(quiet, + 'Found ${widget.credential.issuer}/${widget.credential.name} matching account'); + } else { + printToConsole('Unexpected widget type found: $widget'); + } + }); + + // return the AccountView if there is only one found + var evaluated = matchingAccounts.evaluate(); + return evaluated.isEmpty + ? null + : evaluated.length != 1 + ? null + : evaluated.single.widget as AccountView; + } + + Future openAccountDialog(Account a) async { + var accountView = await findAccount(a); + expect(accountView, isNotNull); + + if (accountView != null) { + await ensureVisible(find.byWidget(accountView)); + await tap(find.byWidget(accountView)); + await shortWait(); + } + } + + Future deleteAccount(Account a, {bool quiet = true}) async { + // only delete account if it exists + var accountView = await findAccount(a); + if (accountView == null) { + testLog(quiet, 'Account to delete does not exist: $a'); + return; + } + + await openAccountDialog(a); + var deleteIconButton = find.byIcon(Icons.delete_outline).hitTestable(); + expect(deleteIconButton, findsOneWidget); + await tap(deleteIconButton); + await longWait(); + + // TODO check dialog shows correct information about account + var deleteButton = find.byKey(deleteAccountBtnKey).hitTestable(); + expect(deleteButton, findsOneWidget); + await tap(deleteButton); + await longWait(); + + // try to find account + var deletedAccountView = await findAccount(a); + expect(deletedAccountView, isNull); + if (deletedAccountView == null) { + testLog(quiet, 'Deleted account $a'); + } + } + + Future renameAccount( + Account a, + String? newIssuer, + String newName, { + bool quiet = true, + }) async { + var accountView = await findAccount(a); + if (accountView == null) { + testLog(quiet, 'Account to rename does not exist: $a'); + return; + } + + await openAccountDialog(a); + var renameIconButton = find.byIcon(Icons.edit_outlined).hitTestable(); + expect(renameIconButton, findsOneWidget); + await tap(renameIconButton); + await longWait(); + + // fill new info + var issuerTextField = find.byKey(renameAccountEditIssuerKey).hitTestable(); + await tap(issuerTextField); + await enterText(issuerTextField, newIssuer ?? ''); + var nameTextField = find.byKey(renameAccountEditNameKey).hitTestable(); + await tap(nameTextField); + await enterText(nameTextField, newName); + await shortestWait(); + + var saveButton = find.byKey(renameAccountBtnSaveKey).hitTestable(); + expect(saveButton, findsOneWidget); + await tap(saveButton); + await longWait(); + + // now the account dialog is shown + // TODO verify it shows correct issuer and name + + // close the account dialog by tapping out of it + await tapAt(const Offset(10, 10)); + await longWait(); + + // verify accounts in the list + var renamedAccount = Account(issuer: newIssuer, name: newName); + var renamedAccountView = await findAccount(renamedAccount); + await shortWait(); + var originalAccountView = await findAccount(a); + expect(renamedAccountView, isNotNull); + expect(originalAccountView, isNull); + if (renamedAccountView != null && originalAccountView == null) { + testLog(quiet, 'Renamed account from $a to $renamedAccount'); + } + } + + void testLog(bool quiet, String message) { + if (!quiet) { + printToConsole(message); + } } } @@ -49,32 +286,87 @@ void main() { final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized(); binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; + var startupParams = {}; + + if (isAndroid) { + // default android parameters + startupParams = {'dlg.beta.enabled': false, 'delay.startup': 5}; + testWidgets('', (WidgetTester tester) async { + // delay first start + await tester.startUp(startupParams); + // remove delay.startup + startupParams = {'dlg.beta.enabled': false}; + }); + } + group('OATH UI tests', () { // Validates that expected UI is present - testWidgets('OATH UI: "Add account" menu item exists', - (WidgetTester tester) async { - await tester.startUp(); + testWidgets('Menu items exist', (WidgetTester tester) async { + await tester.startUp(startupParams); await tester.tapDeviceButton(); - expect(find.byKey(const Key('add oath account')), findsOneWidget); - }); - - testWidgets('OATH-UI: "Set or manage oath password" menu item exists', - (WidgetTester tester) async { - await tester.startUp(); - await tester.tapDeviceButton(); - expect( - find.byKey(const Key('set or manage oath password')), findsOneWidget); - }); - - testWidgets('OATH-UI: "Reset OATH" menu item exists', - (WidgetTester tester) async { - await tester.startUp(); - await tester.tapDeviceButton(); - expect(find.byKey(const Key('reset oath app')), findsOneWidget); + expect(find.byKey(OathDeviceMenu.addAccountKey), findsOneWidget); + expect(find.byKey(OathDeviceMenu.setManagePasswordKey), findsOneWidget); + expect(find.byKey(OathDeviceMenu.resetKey), findsOneWidget); }); }); - group('OATH Password tests', () { + group('OATH Account tests', () { + testWidgets('Create account', (WidgetTester tester) async { + await tester.startUp(startupParams); + + // account with issuer + var testAccount = const Account( + issuer: 'IssuerForTests', + name: 'NameForTests', + secret: 'aaaaaaaaaaaaaaaa', + ); + + await tester.deleteAccount(testAccount); + await tester.addAccount(testAccount, quiet: false); + + // account without issuer + testAccount = const Account( + name: 'NoIssuerName', + secret: 'bbbbbbbbbbbbbbbb', + ); + + await tester.deleteAccount(testAccount); + await tester.addAccount(testAccount, quiet: false); + }); + + // deletes accounts created in previous test + testWidgets('Delete account', (WidgetTester tester) async { + await tester.startUp(startupParams); + + var testAccount = + const Account(issuer: 'IssuerForTests', name: 'NameForTests'); + + await tester.deleteAccount(testAccount, quiet: false); + expect(await tester.findAccount(testAccount), isNull); + + testAccount = const Account(issuer: null, name: 'NoIssuerName'); + await tester.deleteAccount(testAccount, quiet: false); + expect(await tester.findAccount(testAccount), isNull); + }); + + // adds an account, renames, verifies + testWidgets('Rename account', (WidgetTester tester) async { + await tester.startUp(startupParams); + + var testAccount = + const Account(issuer: 'IssuerToRename', name: 'NameToRename'); + + // delete account if it exists + await tester.deleteAccount(testAccount); + await tester.deleteAccount( + const Account(issuer: 'RenamedIssuer', name: 'RenamedName')); + + await tester.addAccount(testAccount); + await tester.renameAccount(testAccount, 'RenamedIssuer', 'RenamedName'); + }); + }); + + group('OATH Password tests', skip: true, () { /* These tests verify that all oath options are verified to function correctly by: 1. setting firsPassword and verifying it @@ -200,7 +492,8 @@ void main() { Tests will verify all TOTP functionality, not yet though: 1. Add 32 TOTP accounts */ - testWidgets('TOTP: Add 32 accounts', (WidgetTester tester) async { + testWidgets('TOTP: Add 32 accounts', skip: true, + (WidgetTester tester) async { await tester.startUp(); for (var i = 0; i < 32; i++) { diff --git a/integration_test/test_util.dart b/integration_test/test_util.dart index 49de8905..32714e89 100644 --- a/integration_test/test_util.dart +++ b/integration_test/test_util.dart @@ -5,6 +5,8 @@ import 'package:yubico_authenticator/app/views/device_button.dart'; import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/desktop/init.dart' as desktop; +import 'android/util.dart'; + Future getAuthenticatorApp() async => isDesktop ? await desktop.initialize([]) : isAndroid @@ -14,12 +16,18 @@ Future getAuthenticatorApp() async => isDesktop extension TestHelper on WidgetTester { /// Taps the device button Future tapDeviceButton() async { - await tap(find.byType(DeviceButton)); + await tap(find.byType(DeviceButton).hitTestable()); await pump(const Duration(milliseconds: 500)); } - Future startUp() async { - await pumpWidget( - await getAuthenticatorApp(), const Duration(milliseconds: 2000)); + + Future startUp([Map? startUpParams]) async { + if (isAndroid) { + return AndroidTestUtils.startUp(this, startUpParams); + } else { + // desktop + return await pumpWidget( + await getAuthenticatorApp(), const Duration(milliseconds: 2000)); + } } } diff --git a/lib/android/qr_scanner/qr_scanner_ui_view.dart b/lib/android/qr_scanner/qr_scanner_ui_view.dart index 2f8cda14..d6e0b0a5 100644 --- a/lib/android/qr_scanner/qr_scanner_ui_view.dart +++ b/lib/android/qr_scanner/qr_scanner_ui_view.dart @@ -52,6 +52,7 @@ class QRScannerUI extends StatelessWidget { onPressed: () { Navigator.of(context).pop(); }, + key: const Key('android.qr_scanner.btn.enter_manually'), child: const Text('Enter manually', style: TextStyle(color: Colors.white))), ], diff --git a/lib/android/views/android_settings_page.dart b/lib/android/views/android_settings_page.dart index b772ecb3..842c9284 100755 --- a/lib/android/views/android_settings_page.dart +++ b/lib/android/views/android_settings_page.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import '../../core/state.dart'; import '../../app/state.dart'; +import '../../core/state.dart'; import '../../widgets/list_title.dart'; import '../../widgets/responsive_dialog.dart'; @@ -32,6 +32,17 @@ enum _TapAction { } } + Key get key { + switch (this) { + case _TapAction.launch: + return const Key('android.settings.on_nfc_tap.launch'); + case _TapAction.copy: + return const Key('android.settings.on_nfc_tap.copy'); + case _TapAction.both: + return const Key('android.settings.on_nfc_tap.both'); + } + } + static _TapAction load(SharedPreferences prefs) { final launchApp = prefs.getBool(_prefNfcOpenApp) ?? true; final copyOtp = prefs.getBool(_prefNfcCopyOtp) ?? false; @@ -103,6 +114,7 @@ class _AndroidSettingsPageState extends ConsumerState { ListTile( title: const Text('On YubiKey NFC tap'), subtitle: Text(tapAction.description), + key: const Key('android.settings.option.on_nfc_tap'), onTap: () async { final newTapAction = await _selectTapAction(context, tapAction); newTapAction.save(prefs); @@ -112,6 +124,7 @@ class _AndroidSettingsPageState extends ConsumerState { ListTile( title: const Text('Keyboard Layout (for static password)'), subtitle: Text(clipKbdLayout), + key: const Key('android.settings.option.keyboard_layout'), enabled: tapAction != _TapAction.launch, onTap: () async { var newValue = await _selectKbdLayout(context, clipKbdLayout); @@ -129,6 +142,7 @@ class _AndroidSettingsPageState extends ConsumerState { : const Text( 'Accounts that require touch need an additional tap over NFC.'), value: nfcBypassTouch, + key: const Key('android.settings.bypass_touch'), onChanged: (value) { prefs.setBool(_prefNfcBypassTouch, value); setState(() {}); @@ -159,8 +173,10 @@ class _AndroidSettingsPageState extends ConsumerState { .map( (e) => RadioListTile<_TapAction>( title: Text(e.description), + key: e.key, value: e, groupValue: tapAction, + toggleable: true, onChanged: (mode) { Navigator.pop(context, e); }), @@ -182,6 +198,8 @@ class _AndroidSettingsPageState extends ConsumerState { (e) => RadioListTile( title: Text(e), value: e, + key: Key('android.settings.keyboard_layout.$e'), + toggleable: true, groupValue: currentKbdLayout, onChanged: (mode) { Navigator.pop(context, e); @@ -204,6 +222,7 @@ class _AndroidSettingsPageState extends ConsumerState { title: Text(e.displayName), value: e, groupValue: themeMode, + toggleable: true, onChanged: (mode) { Navigator.pop(context, e); }, diff --git a/lib/android/views/beta_dialog.dart b/lib/android/views/beta_dialog.dart index d7987359..93220584 100644 --- a/lib/android/views/beta_dialog.dart +++ b/lib/android/views/beta_dialog.dart @@ -12,11 +12,12 @@ class BetaDialog { void request() { WidgetsBinding.instance.addPostFrameCallback((_) async { - await ref.read(prefProvider).reload(); + var sharedPrefs = ref.read(prefProvider); + await sharedPrefs.reload(); var dialogShouldBeShown = - ref.read(prefProvider).getBool(prefBetaDialogShouldBeShown) ?? true; + sharedPrefs.getBool(prefBetaDialogShouldBeShown) ?? true; if (dialogShouldBeShown) { - Future.delayed(Duration.zero, () async { + Future.delayed(const Duration(milliseconds: 100), () async { await showBetaDialog(); }); } @@ -31,6 +32,7 @@ class BetaDialog { return WillPopScope( onWillPop: () async => false, child: AlertDialog( + key: const Key('android.beta.dialog'), content: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, @@ -75,6 +77,7 @@ class BetaDialog { // }, // ), TextButton( + key: const Key('android.beta.dialog.btn.got_it'), style: TextButton.styleFrom( textStyle: Theme.of(context) .textTheme diff --git a/lib/oath/views/delete_account_dialog.dart b/lib/oath/views/delete_account_dialog.dart index 596a713c..f2a145ce 100755 --- a/lib/oath/views/delete_account_dialog.dart +++ b/lib/oath/views/delete_account_dialog.dart @@ -25,6 +25,7 @@ class DeleteAccountDialog extends ConsumerWidget { title: Text(AppLocalizations.of(context)!.oath_delete_account), actions: [ TextButton( + key: const Key('oath.dlg.delete_account.btn.delete'), onPressed: () async { try { await ref diff --git a/lib/oath/views/rename_account_dialog.dart b/lib/oath/views/rename_account_dialog.dart index b193bb08..e147a61b 100755 --- a/lib/oath/views/rename_account_dialog.dart +++ b/lib/oath/views/rename_account_dialog.dart @@ -116,6 +116,7 @@ class _RenameAccountDialogState extends ConsumerState { actions: [ TextButton( onPressed: didChange && isValid ? _submit : null, + key: const Key('oath.dlg.rename_account.btn.save'), child: Text(AppLocalizations.of(context)!.oath_save), ), ], @@ -133,6 +134,7 @@ class _RenameAccountDialogState extends ConsumerState { maxLength: issuerRemaining > 0 ? issuerRemaining : null, buildCounter: buildByteCounterFor(_issuer), inputFormatters: [limitBytesLength(issuerRemaining)], + key: const Key('oath.dlg.rename_account.edit.issuer'), decoration: InputDecoration( border: const OutlineInputBorder(), labelText: AppLocalizations.of(context)!.oath_issuer_optional, @@ -151,6 +153,7 @@ class _RenameAccountDialogState extends ConsumerState { maxLength: nameRemaining, inputFormatters: [limitBytesLength(nameRemaining)], buildCounter: buildByteCounterFor(_account), + key: const Key('oath.dlg.rename_account.edit.name'), decoration: InputDecoration( border: const OutlineInputBorder(), labelText: AppLocalizations.of(context)!.oath_account_name, @@ -162,6 +165,17 @@ class _RenameAccountDialogState extends ConsumerState { : null, prefixIcon: const Icon(Icons.people_alt_outlined), ), + textInputAction: TextInputAction.done, + onChanged: (value) { + setState(() { + _account = value.trim(); + }); + }, + onFieldSubmitted: (_) { + if (didChange && isValid) { + _submit(); + } + }, ), ] .map((e) => Padding( diff --git a/test/android_settings_page_test.dart b/test/android_settings_page_test.dart new file mode 100644 index 00000000..27823bfd --- /dev/null +++ b/test/android_settings_page_test.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/app_localizations.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:yubico_authenticator/android/views/android_settings_page.dart'; +import 'package:yubico_authenticator/core/state.dart'; + +import '../integration_test/android/constants.dart'; + +Widget createMaterialApp({required Widget child}) { + return MaterialApp( + localizationsDelegates: const [ + AppLocalizations.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: const [ + Locale('en', ''), + ], + home: child, + ); +} + +extension _WidgetTesterHelper on WidgetTester { + Future openNfcTapOptionSelection() async { + var widget = find.byKey(settingsOnNfcTapOptionKey).hitTestable(); + expect(widget, findsOneWidget); + await tap(widget); + await pumpAndSettle(); + } + + Future selectLaunchOption() async { + await openNfcTapOptionSelection(); + await tap(find.byKey(settingsOnNfcTapLaunch)); + await pumpAndSettle(); + } + + Future selectCopyOption() async { + await openNfcTapOptionSelection(); + await tap(find.byKey(settingsOnNfcTapCopy)); + await pumpAndSettle(); + } + + Future selectBothOption() async { + await openNfcTapOptionSelection(); + await tap(find.byKey(settingsOnNfcTapBoth)); + await pumpAndSettle(); + } + + ListTile keyboardLayoutListTile() => + find.byKey(settingsKeyboardLayoutOptionKey).evaluate().single.widget + as ListTile; + + Future openKeyboardLayoutOptionSelection() async { + var widget = find.byKey(settingsKeyboardLayoutOptionKey).hitTestable(); + expect(widget, findsOneWidget); + await tap(widget); + await pumpAndSettle(); + } + + Future selectKeyboardLayoutUSOption() async { + await openKeyboardLayoutOptionSelection(); + await tap(find.byKey(settingsKeyboardLayoutUS)); + await pumpAndSettle(); + } + + Future selectKeyboardLayoutDEOption() async { + await openKeyboardLayoutOptionSelection(); + await tap(find.byKey(settingsKeyboardLayoutDE)); + await pumpAndSettle(); + } + + Future selectKeyboardLayoutDECHOption() async { + await openKeyboardLayoutOptionSelection(); + await tap(find.byKey(settingsKeyboardLayoutDECH)); + await pumpAndSettle(); + } + + Future tapBypassTouch() async { + await tap(find.byKey(settingsBypassTouchKey)); + await pumpAndSettle(); + } + +} + +void main() { + + var widget = createMaterialApp(child: const AndroidSettingsPage()); + + testWidgets('NFC Tap options', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues( + {prefNfcOpenApp: true, prefNfcCopyOtp: false}); + + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(ProviderScope( + overrides: [prefProvider.overrideWithValue(sharedPrefs)], + child: widget)); + + // launch - preserves original value + await tester.selectLaunchOption(); + expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true)); + expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(false)); + + // copy + await tester.selectCopyOption(); + expect(sharedPrefs.getBool(prefNfcOpenApp), equals(false)); + expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(true)); + + // both + await tester.selectBothOption(); + expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true)); + expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(true)); + + // launch - changes to value + await tester.selectLaunchOption(); + expect(sharedPrefs.getBool(prefNfcOpenApp), equals(true)); + expect(sharedPrefs.getBool(prefNfcCopyOtp), equals(false)); + }); + + testWidgets('Static password keyboard layout', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues( + {prefNfcOpenApp: true, prefNfcCopyOtp: false, prefClipKbdLayout: 'US'}); + + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(ProviderScope( + overrides: [prefProvider.overrideWithValue(sharedPrefs)], + child: widget)); + + // option is disabled for "open" + expect(tester.keyboardLayoutListTile().enabled, equals(false)); + + // option is enabled for "copy" and "launch" + await tester.selectCopyOption(); + expect(tester.keyboardLayoutListTile().enabled, equals(true)); + + await tester.selectBothOption(); + expect(tester.keyboardLayoutListTile().enabled, equals(true)); + + // US - preserves the original value value + await tester.selectKeyboardLayoutUSOption(); + expect(sharedPrefs.getString(prefClipKbdLayout), equals('US')); + + // DE + await tester.selectKeyboardLayoutDEOption(); + expect(sharedPrefs.getString(prefClipKbdLayout), equals('DE')); + + // DE-CH + await tester.selectKeyboardLayoutDECHOption(); + expect(sharedPrefs.getString(prefClipKbdLayout), equals('DE-CH')); + + // US + await tester.selectKeyboardLayoutUSOption(); + expect(sharedPrefs.getString(prefClipKbdLayout), equals('US')); + }); + + testWidgets('Bypass touch req', (WidgetTester tester) async { + SharedPreferences.setMockInitialValues({prefNfcBypassTouch: false}); + SharedPreferences sharedPrefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget(ProviderScope( + overrides: [prefProvider.overrideWithValue(sharedPrefs)], + child: widget)); + + // change to true + await tester.tapBypassTouch(); + expect(sharedPrefs.getBool(prefNfcBypassTouch), equals(true)); + + // change to false + await tester.tapBypassTouch(); + expect(sharedPrefs.getBool(prefNfcBypassTouch), equals(false)); + }); +}