oath account, andr. settings, andr. beta dlg tests

This commit is contained in:
Adam Velebil 2022-09-11 11:05:00 +02:00
parent 9e567b348c
commit 69228ae482
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
12 changed files with 651 additions and 34 deletions

View File

@ -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));
});
});
}

View File

@ -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';

View File

@ -0,0 +1,11 @@
import 'dart:io';
import 'dart:async';
import 'package:integration_test/integration_test_driver_extended.dart';
Future<void> 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();
}

View File

@ -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<void> startUp(WidgetTester tester,
[Map<dynamic, dynamic>? 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');
}
}
}

View File

@ -1,7 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_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_list.dart';
import 'package:yubico_authenticator/oath/views/account_view.dart';
import 'package:yubico_authenticator/oath/views/oath_screen.dart'; import 'package:yubico_authenticator/oath/views/oath_screen.dart';
import 'test_util.dart'; import 'test_util.dart';
@ -28,20 +30,255 @@ String generateSecret(int index) {
return List.generate(16, (_) => base32(index)).toString(); 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 { extension OathHelper on WidgetTester {
/// Opens the device menu and taps the "Add account" menu item /// Opens the device menu and taps the "Add account" menu item
Future<void> shortestWait() async {
testLog(false, 'shortestWait ${shortestWaitMs}ms');
await pump(const Duration(milliseconds: shortestWaitMs));
}
Future<void> shortWait() async {
testLog(false, 'shortWait ${shortWaitMs}ms');
await pump(const Duration(milliseconds: shortWaitMs));
}
Future<void> longWait() async {
testLog(false, 'longWait ${longWaitMs}ms');
await pump(const Duration(milliseconds: longWaitMs));
}
Future<void> veryLongWait() async {
testLog(false, 'veryLongWait ${veryLongWaitS}s');
await pump(const Duration(seconds: veryLongWaitS));
}
Future<void> tapAddAccount() async { Future<void> tapAddAccount() async {
await tapDeviceButton(); await tapDeviceButton();
await tap(find.byKey(const Key('add oath account'))); await tap(find.byKey(OathDeviceMenu.addAccountKey).hitTestable());
await pump(const Duration(milliseconds: 500)); await longWait();
} }
/// Opens the device menu and taps the "Set/Manage password" menu item /// Opens the device menu and taps the "Set/Manage password" menu item
Future<void> tapSetOrManagePassword() async { Future<void> tapSetOrManagePassword() async {
await pump(const Duration(milliseconds: 300)); await shortWait();
await tapDeviceButton(); await tapDeviceButton();
await tap(find.byKey(const Key('set or manage oath password'))); await tap(find.byKey(OathDeviceMenu.setManagePasswordKey));
await pump(const Duration(milliseconds: 500)); await longWait();
}
Future<void> 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<AccountList?> getAccountList() async {
var accountList = find.byType(AccountList).hitTestable();
expect(accountList, findsOneWidget);
return accountList.evaluate().single.widget as AccountList;
}
Future<AccountView?> 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<void> 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<void> 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<void> 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(); final binding = IntegrationTestWidgetsFlutterBinding.ensureInitialized();
binding.framePolicy = LiveTestWidgetsFlutterBindingFramePolicy.fullyLive; 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', () { group('OATH UI tests', () {
// Validates that expected UI is present // Validates that expected UI is present
testWidgets('OATH UI: "Add account" menu item exists', testWidgets('Menu items exist', (WidgetTester tester) async {
(WidgetTester tester) async { await tester.startUp(startupParams);
await tester.startUp();
await tester.tapDeviceButton(); await tester.tapDeviceButton();
expect(find.byKey(const Key('add oath account')), findsOneWidget); expect(find.byKey(OathDeviceMenu.addAccountKey), findsOneWidget);
}); expect(find.byKey(OathDeviceMenu.setManagePasswordKey), findsOneWidget);
expect(find.byKey(OathDeviceMenu.resetKey), 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);
}); });
}); });
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: These tests verify that all oath options are verified to function correctly by:
1. setting firsPassword and verifying it 1. setting firsPassword and verifying it
@ -200,7 +492,8 @@ void main() {
Tests will verify all TOTP functionality, not yet though: Tests will verify all TOTP functionality, not yet though:
1. Add 32 TOTP accounts 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(); await tester.startUp();
for (var i = 0; i < 32; i++) { for (var i = 0; i < 32; i++) {

View File

@ -5,6 +5,8 @@ import 'package:yubico_authenticator/app/views/device_button.dart';
import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/core/state.dart';
import 'package:yubico_authenticator/desktop/init.dart' as desktop; import 'package:yubico_authenticator/desktop/init.dart' as desktop;
import 'android/util.dart';
Future<Widget> getAuthenticatorApp() async => isDesktop Future<Widget> getAuthenticatorApp() async => isDesktop
? await desktop.initialize([]) ? await desktop.initialize([])
: isAndroid : isAndroid
@ -14,12 +16,18 @@ Future<Widget> getAuthenticatorApp() async => isDesktop
extension TestHelper on WidgetTester { extension TestHelper on WidgetTester {
/// Taps the device button /// Taps the device button
Future<void> tapDeviceButton() async { Future<void> tapDeviceButton() async {
await tap(find.byType(DeviceButton)); await tap(find.byType(DeviceButton).hitTestable());
await pump(const Duration(milliseconds: 500)); await pump(const Duration(milliseconds: 500));
} }
Future<void> startUp() async {
await pumpWidget( Future<void> startUp([Map<dynamic, dynamic>? startUpParams]) async {
if (isAndroid) {
return AndroidTestUtils.startUp(this, startUpParams);
} else {
// desktop
return await pumpWidget(
await getAuthenticatorApp(), const Duration(milliseconds: 2000)); await getAuthenticatorApp(), const Duration(milliseconds: 2000));
} }
}
} }

View File

@ -52,6 +52,7 @@ class QRScannerUI extends StatelessWidget {
onPressed: () { onPressed: () {
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
key: const Key('android.qr_scanner.btn.enter_manually'),
child: const Text('Enter manually', child: const Text('Enter manually',
style: TextStyle(color: Colors.white))), style: TextStyle(color: Colors.white))),
], ],

View File

@ -2,8 +2,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../core/state.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../core/state.dart';
import '../../widgets/list_title.dart'; import '../../widgets/list_title.dart';
import '../../widgets/responsive_dialog.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) { static _TapAction load(SharedPreferences prefs) {
final launchApp = prefs.getBool(_prefNfcOpenApp) ?? true; final launchApp = prefs.getBool(_prefNfcOpenApp) ?? true;
final copyOtp = prefs.getBool(_prefNfcCopyOtp) ?? false; final copyOtp = prefs.getBool(_prefNfcCopyOtp) ?? false;
@ -103,6 +114,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
ListTile( ListTile(
title: const Text('On YubiKey NFC tap'), title: const Text('On YubiKey NFC tap'),
subtitle: Text(tapAction.description), subtitle: Text(tapAction.description),
key: const Key('android.settings.option.on_nfc_tap'),
onTap: () async { onTap: () async {
final newTapAction = await _selectTapAction(context, tapAction); final newTapAction = await _selectTapAction(context, tapAction);
newTapAction.save(prefs); newTapAction.save(prefs);
@ -112,6 +124,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
ListTile( ListTile(
title: const Text('Keyboard Layout (for static password)'), title: const Text('Keyboard Layout (for static password)'),
subtitle: Text(clipKbdLayout), subtitle: Text(clipKbdLayout),
key: const Key('android.settings.option.keyboard_layout'),
enabled: tapAction != _TapAction.launch, enabled: tapAction != _TapAction.launch,
onTap: () async { onTap: () async {
var newValue = await _selectKbdLayout(context, clipKbdLayout); var newValue = await _selectKbdLayout(context, clipKbdLayout);
@ -129,6 +142,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
: const Text( : const Text(
'Accounts that require touch need an additional tap over NFC.'), 'Accounts that require touch need an additional tap over NFC.'),
value: nfcBypassTouch, value: nfcBypassTouch,
key: const Key('android.settings.bypass_touch'),
onChanged: (value) { onChanged: (value) {
prefs.setBool(_prefNfcBypassTouch, value); prefs.setBool(_prefNfcBypassTouch, value);
setState(() {}); setState(() {});
@ -159,8 +173,10 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
.map( .map(
(e) => RadioListTile<_TapAction>( (e) => RadioListTile<_TapAction>(
title: Text(e.description), title: Text(e.description),
key: e.key,
value: e, value: e,
groupValue: tapAction, groupValue: tapAction,
toggleable: true,
onChanged: (mode) { onChanged: (mode) {
Navigator.pop(context, e); Navigator.pop(context, e);
}), }),
@ -182,6 +198,8 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
(e) => RadioListTile<String>( (e) => RadioListTile<String>(
title: Text(e), title: Text(e),
value: e, value: e,
key: Key('android.settings.keyboard_layout.$e'),
toggleable: true,
groupValue: currentKbdLayout, groupValue: currentKbdLayout,
onChanged: (mode) { onChanged: (mode) {
Navigator.pop(context, e); Navigator.pop(context, e);
@ -204,6 +222,7 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
title: Text(e.displayName), title: Text(e.displayName),
value: e, value: e,
groupValue: themeMode, groupValue: themeMode,
toggleable: true,
onChanged: (mode) { onChanged: (mode) {
Navigator.pop(context, e); Navigator.pop(context, e);
}, },

View File

@ -12,11 +12,12 @@ class BetaDialog {
void request() { void request() {
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
await ref.read(prefProvider).reload(); var sharedPrefs = ref.read(prefProvider);
await sharedPrefs.reload();
var dialogShouldBeShown = var dialogShouldBeShown =
ref.read(prefProvider).getBool(prefBetaDialogShouldBeShown) ?? true; sharedPrefs.getBool(prefBetaDialogShouldBeShown) ?? true;
if (dialogShouldBeShown) { if (dialogShouldBeShown) {
Future.delayed(Duration.zero, () async { Future.delayed(const Duration(milliseconds: 100), () async {
await showBetaDialog(); await showBetaDialog();
}); });
} }
@ -31,6 +32,7 @@ class BetaDialog {
return WillPopScope( return WillPopScope(
onWillPop: () async => false, onWillPop: () async => false,
child: AlertDialog( child: AlertDialog(
key: const Key('android.beta.dialog'),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.start,
@ -75,6 +77,7 @@ class BetaDialog {
// }, // },
// ), // ),
TextButton( TextButton(
key: const Key('android.beta.dialog.btn.got_it'),
style: TextButton.styleFrom( style: TextButton.styleFrom(
textStyle: Theme.of(context) textStyle: Theme.of(context)
.textTheme .textTheme

View File

@ -25,6 +25,7 @@ class DeleteAccountDialog extends ConsumerWidget {
title: Text(AppLocalizations.of(context)!.oath_delete_account), title: Text(AppLocalizations.of(context)!.oath_delete_account),
actions: [ actions: [
TextButton( TextButton(
key: const Key('oath.dlg.delete_account.btn.delete'),
onPressed: () async { onPressed: () async {
try { try {
await ref await ref

View File

@ -116,6 +116,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
actions: [ actions: [
TextButton( TextButton(
onPressed: didChange && isValid ? _submit : null, onPressed: didChange && isValid ? _submit : null,
key: const Key('oath.dlg.rename_account.btn.save'),
child: Text(AppLocalizations.of(context)!.oath_save), child: Text(AppLocalizations.of(context)!.oath_save),
), ),
], ],
@ -133,6 +134,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
maxLength: issuerRemaining > 0 ? issuerRemaining : null, maxLength: issuerRemaining > 0 ? issuerRemaining : null,
buildCounter: buildByteCounterFor(_issuer), buildCounter: buildByteCounterFor(_issuer),
inputFormatters: [limitBytesLength(issuerRemaining)], inputFormatters: [limitBytesLength(issuerRemaining)],
key: const Key('oath.dlg.rename_account.edit.issuer'),
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.oath_issuer_optional, labelText: AppLocalizations.of(context)!.oath_issuer_optional,
@ -151,6 +153,7 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
maxLength: nameRemaining, maxLength: nameRemaining,
inputFormatters: [limitBytesLength(nameRemaining)], inputFormatters: [limitBytesLength(nameRemaining)],
buildCounter: buildByteCounterFor(_account), buildCounter: buildByteCounterFor(_account),
key: const Key('oath.dlg.rename_account.edit.name'),
decoration: InputDecoration( decoration: InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: AppLocalizations.of(context)!.oath_account_name, labelText: AppLocalizations.of(context)!.oath_account_name,
@ -162,6 +165,17 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
: null, : null,
prefixIcon: const Icon(Icons.people_alt_outlined), prefixIcon: const Icon(Icons.people_alt_outlined),
), ),
textInputAction: TextInputAction.done,
onChanged: (value) {
setState(() {
_account = value.trim();
});
},
onFieldSubmitted: (_) {
if (didChange && isValid) {
_submit();
}
},
), ),
] ]
.map((e) => Padding( .map((e) => Padding(

View File

@ -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<void> openNfcTapOptionSelection() async {
var widget = find.byKey(settingsOnNfcTapOptionKey).hitTestable();
expect(widget, findsOneWidget);
await tap(widget);
await pumpAndSettle();
}
Future<void> selectLaunchOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(settingsOnNfcTapLaunch));
await pumpAndSettle();
}
Future<void> selectCopyOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(settingsOnNfcTapCopy));
await pumpAndSettle();
}
Future<void> selectBothOption() async {
await openNfcTapOptionSelection();
await tap(find.byKey(settingsOnNfcTapBoth));
await pumpAndSettle();
}
ListTile keyboardLayoutListTile() =>
find.byKey(settingsKeyboardLayoutOptionKey).evaluate().single.widget
as ListTile;
Future<void> openKeyboardLayoutOptionSelection() async {
var widget = find.byKey(settingsKeyboardLayoutOptionKey).hitTestable();
expect(widget, findsOneWidget);
await tap(widget);
await pumpAndSettle();
}
Future<void> selectKeyboardLayoutUSOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(settingsKeyboardLayoutUS));
await pumpAndSettle();
}
Future<void> selectKeyboardLayoutDEOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(settingsKeyboardLayoutDE));
await pumpAndSettle();
}
Future<void> selectKeyboardLayoutDECHOption() async {
await openKeyboardLayoutOptionSelection();
await tap(find.byKey(settingsKeyboardLayoutDECH));
await pumpAndSettle();
}
Future<void> 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));
});
}