From c4b0517e97f0feb033637eb7dc03dcd00bee1b52 Mon Sep 17 00:00:00 2001 From: Adam Velebil Date: Wed, 7 Feb 2024 13:20:18 +0100 Subject: [PATCH] support multiple connected and approved keys --- integration_test/utils/oath_test_util.dart | 20 +- integration_test/utils/test_util.dart | 221 ++++++++++++++++----- 2 files changed, 176 insertions(+), 65 deletions(-) diff --git a/integration_test/utils/oath_test_util.dart b/integration_test/utils/oath_test_util.dart index 6b2a97a2..bf4c0014 100644 --- a/integration_test/utils/oath_test_util.dart +++ b/integration_test/utils/oath_test_util.dart @@ -303,19 +303,17 @@ extension OathFunctions on WidgetTester { /// Factory reset OATH application Future resetOATH() async { - /// 1. open drawer if needed - await openDrawer(); + final targetKey = approvedKeys[0]; // only reset approved keys! + + /// 1. make sure we are using approved key + await switchToKey(targetKey); await shortWait(); - /// 2. then click the meatball button+'Factory reset' for correct S/N - await collectYubiKeyInformation(); - final approvedSerialNumbers = await getApprovedSerialNumbers(); - if (approvedSerialNumbers.contains(yubiKeySerialNumber)) { - await tap(find.byKey(yubikeyPopupMenuButton).hitTestable()); - await shortWait(); - await tap(find.byKey(yubikeyFactoryResetMenuButton).hitTestable()); - await longWait(); - } + /// 2. open the key menu + await tapPopupMenu(targetKey); + await shortWait(); + await tap(find.byKey(yubikeyFactoryResetMenuButton).hitTestable()); + await longWait(); /// 3. then toggle 'OATH' in the 'Factory reset' reset_dialog.dart await tap(find.byKey(factoryResetPickResetOath)); diff --git a/integration_test/utils/test_util.dart b/integration_test/utils/test_util.dart index 6a48eddb..c00564fe 100644 --- a/integration_test/utils/test_util.dart +++ b/integration_test/utils/test_util.dart @@ -14,11 +14,12 @@ * limitations under the License. */ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:flutter_test/flutter_test.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:yubico_authenticator/app/views/keys.dart' as app_keys; +import 'package:yubico_authenticator/app/views/device_picker.dart'; import 'package:yubico_authenticator/app/views/keys.dart'; import 'package:yubico_authenticator/core/state.dart'; import 'package:yubico_authenticator/management/views/keys.dart'; @@ -30,10 +31,82 @@ const shortWaitMs = 200; const longWaitMs = 500; const ultraLongWaitMs = 5000; -/// information about YubiKey as seen by the app -String? yubiKeyName; -String? yubiKeyFirmware; -String? yubiKeySerialNumber; +class ConnectedKey { + final String? name; + final String? serialNumber; + final String? firmware; + + const ConnectedKey(this.name, this.serialNumber, this.firmware); + + @override + String toString() { + return 'ConnectedYubiKey{name: $name, serialNumber: $serialNumber, firmware: $firmware}'; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConnectedKey && + runtimeType == other.runtimeType && + name == other.name && + serialNumber == other.serialNumber && + firmware == other.firmware; + + @override + int get hashCode => name.hashCode ^ serialNumber.hashCode ^ firmware.hashCode; +} + +extension ConnectedKeyExt on List { + String serialNumbers() => where((e) => e.serialNumber != null) + .map((e) => e.serialNumber!) + .toList() + .join(','); + + String prettyList() { + var retval = ''; + if (this.isEmpty) { + return 'No keys.'; + } + for (final (index, connectedKey) in indexed) { + retval += '${index + 1}: ${connectedKey.name} / ' + 'SN: ${connectedKey.serialNumber} / ' + 'FW: ${connectedKey.firmware}\n'; + } + return retval; + } +} + +extension ListTileInfoExt on ListTile { + ConnectedKey getKeyInfo() { + final itemName = (title as Text).data; + String? itemSerialNumber; + String? itemFirmware; + var subtitle = (this.subtitle as Text?)?.data; + + if (subtitle != null) { + RegExpMatch? match = + RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)').firstMatch(subtitle); + if (match != null) { + itemSerialNumber = match.group(1); + itemFirmware = match.group(2); + } else { + match = RegExp(r'F/W: (\d\.\d\.\d)').firstMatch(subtitle); + if (match != null) { + itemFirmware = match.group(1); + } + } + } + + return ConnectedKey( + itemName, + itemSerialNumber, + itemFirmware, + ); + } +} + +/// contains information about connected YubiKeys approved for testing +final approvedKeys = []; bool collectedYubiKeyInformation = false; extension AppWidgetTester on WidgetTester { @@ -75,8 +148,11 @@ extension AppWidgetTester on WidgetTester { } Future tapActionIconButton() async { - await tap(findActionIconButton()); - await pump(const Duration(milliseconds: 500)); + final actionIconButtonFinder = findActionIconButton(); + if (actionIconButtonFinder.evaluate().isNotEmpty) { + await tap(actionIconButtonFinder); + await pump(const Duration(milliseconds: 500)); + } } Future tapTopLeftCorner() async { @@ -87,12 +163,15 @@ extension AppWidgetTester on WidgetTester { /// Drawer helpers bool hasDrawer() => scaffoldGlobalKey.currentState!.hasDrawer; - /// Open drawer - Future openDrawer() async { - if (hasDrawer()) { + /// Open drawer if not opened + /// return open state + Future openDrawer() async { + bool isOpened = isDrawerOpened(); + if (hasDrawer() && !isOpened) { scaffoldGlobalKey.currentState!.openDrawer(); await pump(const Duration(milliseconds: 500)); } + return isOpened; } /// Close drawer @@ -120,6 +199,41 @@ extension AppWidgetTester on WidgetTester { await longWait(); } + Future switchToKey(ConnectedKey key) async { + await openDrawer(); + final drawerDevicesFinder = await getDrawerDevices(); + var itemIndex = 0; + for (var element in drawerDevicesFinder.evaluate()) { + final listTile = element.widget as ListTile; + if (key == listTile.getKeyInfo()) { + await tap(drawerDevicesFinder.at(itemIndex)); + break; + } + itemIndex++; + } + + await closeDrawer(); + } + + Future tapPopupMenu(ConnectedKey key) async { + await openDrawer(); + final drawerDevicesFinder = await getDrawerDevices(); + var itemIndex = 0; + for (var element in drawerDevicesFinder.evaluate()) { + final listTile = element.widget as ListTile; + if (key == listTile.getKeyInfo()) { + // find the popup menu + final popupMenu = find.descendant( + of: drawerDevicesFinder.at(itemIndex), + matching: find.byKey(yubikeyPopupMenuButton)); + expect(popupMenu, findsOne); + await tap(popupMenu); + break; + } + itemIndex++; + } + } + /// Management screen Future openManagementScreen() async { if (!isDrawerOpened()) { @@ -161,9 +275,10 @@ extension AppWidgetTester on WidgetTester { testLog(false, 'Failed to read $approvedKeysResource'); } - return (approved + (approved.isEmpty ? ',' : '') + envVar) + return (approved + (approved.isEmpty ? '' : ',') + envVar) .split(',') .map((e) => e.trim()) + .sorted() .toList(growable: false); } @@ -172,19 +287,29 @@ extension AppWidgetTester on WidgetTester { ? await android_test_util.startUp(this, startUpParams) : await desktop_test_util.startUp(this, startUpParams); - await collectYubiKeyInformation(); + if (!collectedYubiKeyInformation) { + final connectedKeys = await collectYubiKeyInformation(); + if (connectedKeys.isEmpty) { + fail('No YubiKey connected'); + } - if (yubiKeySerialNumber == null) { - fail('No YubiKey connected'); - } + final approvedSerialNumbers = await getApprovedSerialNumbers(); - final approvedSerialNumbers = await getApprovedSerialNumbers(); + approvedKeys.addAll(connectedKeys + .where( + (element) => approvedSerialNumbers.contains(element.serialNumber)) + .toList(growable: false)); - if (!approvedSerialNumbers.contains(yubiKeySerialNumber)) { - fail('YubiKey with S/N $yubiKeySerialNumber is not approved for ' - 'integration tests.\nUse --dart-define=' - 'YA_TEST_APPROVED_KEY_SN=$yubiKeySerialNumber test ' - 'parameter to approve it.'); + testLog(false, 'Approved keys:'); + testLog(false, approvedKeys.prettyList()); + + if (approvedKeys.isEmpty) { + final connectedSerials = connectedKeys.serialNumbers(); + fail('None of the connected YubiKeys ($connectedSerials) ' + 'is approved for integration tests.\nUse --dart-define=' + 'YA_TEST_APPROVED_KEY_SN=$connectedSerials test ' + 'parameter to approve it.'); + } } return result; @@ -197,48 +322,36 @@ extension AppWidgetTester on WidgetTester { } /// get key information - Future collectYubiKeyInformation() async { - if (collectedYubiKeyInformation) { - return; - } + Future getDrawerDevices() async { + var devicePickerContent = + await waitForFinder(find.byType(DevicePickerContent)); + + var deviceRows = find.descendant( + of: devicePickerContent, matching: find.byType(ListTile)); + + return deviceRows; + } + + Future> collectYubiKeyInformation() async { + final connectedKeys = []; await openDrawer(); - var deviceInfo = - await waitForFinder(find.byKey(app_keys.deviceInfoListTile)); - if (deviceInfo.evaluate().isNotEmpty) { - ListTile lt = find - .descendant(of: deviceInfo, matching: find.byType(ListTile)) - .evaluate() - .single - .widget as ListTile; - - yubiKeyName = (lt.title as Text).data; - var subtitle = (lt.subtitle as Text?)?.data; - - if (subtitle != null) { - RegExpMatch? match = - RegExp(r'S/N: (\d.*) F/W: (\d\.\d\.\d)').firstMatch(subtitle); - if (match != null) { - yubiKeySerialNumber = match.group(1); - yubiKeyFirmware = match.group(2); - } else { - match = RegExp(r'F/W: (\d\.\d\.\d)').firstMatch(subtitle); - if (match != null) { - yubiKeyFirmware = match.group(1); - } - } - } + final drawerDevicesFinder = await getDrawerDevices(); + for (var element in drawerDevicesFinder.evaluate()) { + final listTile = element.widget as ListTile; + connectedKeys.add(listTile.getKeyInfo()); } // close the opened menu await closeDrawer(); - if (yubiKeySerialNumber != null) { - testLog(false, - 'Connected YubiKey: $yubiKeySerialNumber/$yubiKeyFirmware - $yubiKeyName'); - } + testLog(false, 'Connected YubiKeys:'); + testLog(false, connectedKeys.prettyList()); + collectedYubiKeyInformation = true; + + return connectedKeys; } bool isTextButtonEnabled(Key buttonKey) {