702 lines
20 KiB
Dart
702 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:ikar/core/storage/app_database.dart';
|
|
import 'package:ikar/frontend/screens/webapp/web_app_biometry.dart';
|
|
import 'package:ikar/frontend/screens/webapp/web_app_bridge.dart';
|
|
import 'package:path_provider_platform_interface/path_provider_platform_interface.dart';
|
|
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class _SyntheticPathProvider extends PathProviderPlatform
|
|
with MockPlatformInterfaceMixin {
|
|
_SyntheticPathProvider(this.directory);
|
|
|
|
final String directory;
|
|
|
|
@override
|
|
Future<String?> getApplicationSupportPath() async => directory;
|
|
}
|
|
|
|
class _FakeBiometry implements WebAppBiometry {
|
|
List<String> available = ['finger'];
|
|
bool? consent = true;
|
|
bool passes = true;
|
|
Future<void> Function()? onAuthenticate;
|
|
final List<String?> consentReasons = [];
|
|
final List<String?> authReasons = [];
|
|
|
|
@override
|
|
Future<List<String>> types() async => available;
|
|
|
|
@override
|
|
Future<bool?> confirmAccess(String? reason) async {
|
|
consentReasons.add(reason);
|
|
return consent;
|
|
}
|
|
|
|
@override
|
|
Future<bool> authenticate(String? reason) async {
|
|
authReasons.add(reason);
|
|
await onAuthenticate?.call();
|
|
return passes;
|
|
}
|
|
}
|
|
|
|
void main() {
|
|
TestWidgetsFlutterBinding.ensureInitialized();
|
|
late List<(String, Map<String, dynamic>, bool)> sent;
|
|
late int closeCalls;
|
|
|
|
WebAppBridge buildBridge({
|
|
bool privateChannel = false,
|
|
String entryPoint = WebAppEntryPoint.webApp,
|
|
WebAppBiometry? biometry,
|
|
}) {
|
|
final bridge = WebAppBridge(
|
|
botId: 777,
|
|
entryPoint: entryPoint,
|
|
privateChannel: privateChannel,
|
|
contextResolver: () => null,
|
|
viewportResolver: () => const Size(420, 800),
|
|
onClose: () => closeCalls++,
|
|
biometry: biometry,
|
|
emitter: (method, payload, private) => sent.add((
|
|
method,
|
|
jsonDecode(payload) as Map<String, dynamic>,
|
|
private,
|
|
)),
|
|
);
|
|
if (biometry != null) bridge.registerGesture();
|
|
return bridge;
|
|
}
|
|
|
|
setUp(() {
|
|
sent = [];
|
|
closeCalls = 0;
|
|
});
|
|
|
|
test('reports the launch context it was created with', () async {
|
|
final bridge = buildBridge(entryPoint: WebAppEntryPoint.inlineButton);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppGetLaunchContext',
|
|
'{"requestId":"r1"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent, hasLength(1));
|
|
expect(sent.first.$1, 'WebAppGetLaunchContext');
|
|
expect(sent.first.$2, {'requestId': 'r1', 'entryPoint': 'inline_button'});
|
|
});
|
|
|
|
test('answers viewport requests with the current webview size', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppGetViewportSize',
|
|
'{"requestId":"r2"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2['width'], 420);
|
|
expect(sent.first.$2['height'], 800);
|
|
expect(sent.first.$2['isStateStable'], isTrue);
|
|
});
|
|
|
|
test('rejects an unknown method with the client error code', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppSomethingElse',
|
|
'{"requestId":"r3"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2['error'], {
|
|
'code': 'client.unsupported_method.unsupported_method',
|
|
});
|
|
});
|
|
|
|
test('stays silent for methods that never get an answer', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent('WebAppReady', '{}', false);
|
|
await bridge.handleEvent('WebAppStat', '{}', false);
|
|
|
|
expect(sent, isEmpty);
|
|
});
|
|
|
|
test('drops gesture-gated methods until the user touches the page', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppShare',
|
|
'{"requestId":"r4","text":"hi"}',
|
|
false,
|
|
);
|
|
expect(sent, isEmpty);
|
|
|
|
bridge.registerGesture();
|
|
await bridge.handleEvent('WebAppShare', '{"requestId":"r5"}', false);
|
|
|
|
expect(sent.single.$2['error'], {
|
|
'code': 'client.web_app_share.invalid_request',
|
|
});
|
|
});
|
|
|
|
test('ignores private-channel events when the channel is off', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppVerifyMobileId',
|
|
'{"requestId":"r6","url":"https://example.test/verify"}',
|
|
true,
|
|
);
|
|
|
|
expect(sent, isEmpty);
|
|
});
|
|
|
|
test('reports malformed payloads as a decode error', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent('WebAppGetViewportSize', 'not-json', false);
|
|
|
|
expect(sent, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'tracks the back button and closing behaviour the app asked for',
|
|
() async {
|
|
final bridge = buildBridge();
|
|
|
|
expect(bridge.handlesBackButton, isFalse);
|
|
expect(bridge.needsCloseConfirmation, isFalse);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppSetupBackButton',
|
|
'{"isVisible":true}',
|
|
false,
|
|
);
|
|
await bridge.handleEvent(
|
|
'WebAppSetupClosingBehavior',
|
|
'{"needConfirmation":true}',
|
|
false,
|
|
);
|
|
|
|
expect(bridge.handlesBackButton, isTrue);
|
|
expect(bridge.needsCloseConfirmation, isTrue);
|
|
|
|
bridge.notifyBackPressed();
|
|
expect(sent.single.$1, 'WebAppBackButtonPressed');
|
|
},
|
|
);
|
|
|
|
test('closes the screen when the app asks to', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent('WebAppClose', '{}', false);
|
|
|
|
expect(closeCalls, 1);
|
|
});
|
|
|
|
test('echoes the screen capture behaviour back', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppSetupScreenCaptureBehavior',
|
|
'{"requestId":"r7","isScreenCaptureEnabled":true}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2, {'requestId': 'r7', 'isScreenCaptureEnabled': true});
|
|
});
|
|
|
|
test('answers NFC availability without pretending to support it', () async {
|
|
final bridge = buildBridge();
|
|
|
|
await bridge.handleEvent('WebAppNfcGetInfo', '{"requestId":"r8"}', false);
|
|
await bridge.handleEvent(
|
|
'WebAppNfcEmulateNfcTag',
|
|
'{"requestId":"r9"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent[0].$2, {
|
|
'requestId': 'r8',
|
|
'available': false,
|
|
'enabled': false,
|
|
});
|
|
expect(sent[1].$2['error'], {
|
|
'code': 'client.nfc_emulate_nfc_tag.not_supported',
|
|
});
|
|
});
|
|
|
|
group('biometry', () {
|
|
const accountId = 900501;
|
|
const botId = 777;
|
|
const tokenKey = 'webapp_bio_${accountId}_$botId';
|
|
const channel = MethodChannel(
|
|
'plugins.it_nomads.com/flutter_secure_storage',
|
|
);
|
|
late Map<String, String> secureValues;
|
|
late _FakeBiometry biometry;
|
|
|
|
Map<String, dynamic> errorOf(
|
|
String slug,
|
|
String reason,
|
|
String requestId,
|
|
) => {
|
|
'requestId': requestId,
|
|
'error': {'code': 'client.$slug.$reason'},
|
|
};
|
|
|
|
Future<void> grantAccess({String? token}) async {
|
|
await AppDatabase.setWebAppBiometryAccess(
|
|
accountId,
|
|
botId,
|
|
requested: true,
|
|
granted: true,
|
|
);
|
|
if (token != null) secureValues[tokenKey] = token;
|
|
}
|
|
|
|
setUp(() async {
|
|
SharedPreferences.setMockInitialValues({
|
|
'active_account_id': '$accountId',
|
|
});
|
|
final directory = Directory.systemTemp.createTempSync(
|
|
'synthetic_biometry_test',
|
|
);
|
|
PathProviderPlatform.instance = _SyntheticPathProvider(directory.path);
|
|
secureValues = {};
|
|
biometry = _FakeBiometry();
|
|
final messenger =
|
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
|
final arguments = call.arguments as Map;
|
|
final key = arguments['key'] as String?;
|
|
switch (call.method) {
|
|
case 'read':
|
|
return secureValues[key];
|
|
case 'write':
|
|
secureValues[key!] = arguments['value'] as String;
|
|
return null;
|
|
case 'delete':
|
|
secureValues.remove(key);
|
|
return null;
|
|
default:
|
|
throw StateError('Unexpected secure storage call ${call.method}');
|
|
}
|
|
});
|
|
addTearDown(() async {
|
|
messenger.setMockMethodCallHandler(channel, null);
|
|
await AppDatabase.close();
|
|
if (directory.existsSync()) directory.deleteSync(recursive: true);
|
|
});
|
|
await AppDatabase.init();
|
|
await AppDatabase.saveProfile(
|
|
ProfileData(
|
|
id: accountId,
|
|
firstName: 'Synthetic biometry owner',
|
|
phone: 100501,
|
|
country: 'ZZ',
|
|
accountStatus: 0,
|
|
updateTime: 1,
|
|
),
|
|
);
|
|
});
|
|
|
|
test(
|
|
'account switch during biometric challenge discloses no token',
|
|
() async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
biometry.onAuthenticate = () async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
await prefs.setString('active_account_id', '900502');
|
|
};
|
|
final bridge = buildBridge(biometry: biometry);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"switch"}',
|
|
false,
|
|
);
|
|
expect(
|
|
sent.single.$2,
|
|
errorOf('biometry_request_auth', 'access_denied', 'switch'),
|
|
);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'revoked access during biometric challenge cannot overwrite token',
|
|
() async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
biometry.onAuthenticate = () async {
|
|
await AppDatabase.setWebAppBiometryAccess(
|
|
accountId,
|
|
botId,
|
|
requested: true,
|
|
granted: false,
|
|
);
|
|
};
|
|
final bridge = buildBridge(biometry: biometry);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"revoke","token":"replacement"}',
|
|
false,
|
|
);
|
|
expect(
|
|
sent.single.$2,
|
|
errorOf('biometry_update_token', 'access_denied', 'revoke'),
|
|
);
|
|
expect(secureValues[tokenKey], 'synthetic-token');
|
|
},
|
|
);
|
|
|
|
test('reports what the device offers before any access is given', () async {
|
|
biometry.available = ['face', 'finger'];
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryGetInfo',
|
|
'{"requestId":"synthetic-info"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.single.$2, {
|
|
'requestId': 'synthetic-info',
|
|
'available': true,
|
|
'type': ['face', 'finger'],
|
|
'accessRequested': false,
|
|
'accessGranted': false,
|
|
'tokenSaved': false,
|
|
'deviceId': null,
|
|
});
|
|
expect(biometry.consentReasons, isEmpty);
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
|
|
test('reports biometry as unavailable on a device without it', () async {
|
|
biometry.available = [];
|
|
await grantAccess(token: 'synthetic-token');
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryGetInfo',
|
|
'{"requestId":"synthetic-info"}',
|
|
false,
|
|
);
|
|
for (final method in [
|
|
'WebAppBiometryRequestAccess',
|
|
'WebAppBiometryRequestAuth',
|
|
'WebAppBiometryOpenSettings',
|
|
]) {
|
|
await bridge.handleEvent(
|
|
method,
|
|
'{"requestId":"synthetic-off"}',
|
|
false,
|
|
);
|
|
}
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"synthetic-off","token":"synthetic-replacement"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2['available'], isFalse);
|
|
expect(sent.first.$2['type'], ['unknown']);
|
|
expect(sent.skip(1).map((event) => event.$2), [
|
|
errorOf('biometry_request_access', 'not_supported', 'synthetic-off'),
|
|
errorOf('biometry_request_auth', 'not_supported', 'synthetic-off'),
|
|
errorOf('biometry_open_settings', 'not_supported', 'synthetic-off'),
|
|
errorOf('biometry_update_token', 'not_supported', 'synthetic-off'),
|
|
]);
|
|
expect(secureValues, {tokenKey: 'synthetic-token'});
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
|
|
test('asks the user once before granting access', () async {
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAccess',
|
|
'{"requestId":"synthetic-access","reason":" Synthetic reason "}',
|
|
false,
|
|
);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAccess',
|
|
'{"requestId":"synthetic-again"}',
|
|
false,
|
|
);
|
|
|
|
expect(biometry.consentReasons, ['Synthetic reason']);
|
|
expect(biometry.authReasons, isEmpty);
|
|
for (final event in sent) {
|
|
expect(event.$2['accessRequested'], isTrue);
|
|
expect(event.$2['accessGranted'], isTrue);
|
|
expect(event.$2['tokenSaved'], isFalse);
|
|
}
|
|
expect(await AppDatabase.getWebAppBiometryAccess(accountId, botId), (
|
|
true,
|
|
true,
|
|
));
|
|
});
|
|
|
|
test('remembers a refusal and keeps the device id private', () async {
|
|
biometry.consent = false;
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAccess',
|
|
'{"requestId":"synthetic-access"}',
|
|
false,
|
|
);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-auth"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2['accessRequested'], isTrue);
|
|
expect(sent.first.$2['accessGranted'], isFalse);
|
|
expect(sent.first.$2['deviceId'], isNull);
|
|
expect(
|
|
sent.last.$2,
|
|
errorOf('biometry_request_auth', 'permission_denied', 'synthetic-auth'),
|
|
);
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
|
|
test('grants nothing when the question cannot be shown', () async {
|
|
biometry.consent = null;
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAccess',
|
|
'{"requestId":"synthetic-access"}',
|
|
false,
|
|
);
|
|
|
|
expect(
|
|
sent.single.$2,
|
|
errorOf('biometry_request_access', 'request_error', 'synthetic-access'),
|
|
);
|
|
expect(await AppDatabase.getWebAppBiometryAccess(accountId, botId), (
|
|
false,
|
|
false,
|
|
));
|
|
});
|
|
|
|
test(
|
|
'hands the token over only after the biometric check passes',
|
|
() async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
biometry.passes = false;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-denied","reason":"Synthetic reason"}',
|
|
false,
|
|
);
|
|
biometry.passes = true;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-passed"}',
|
|
false,
|
|
);
|
|
|
|
expect(
|
|
sent.first.$2,
|
|
errorOf('biometry_request_auth', 'auth_failed', 'synthetic-denied'),
|
|
);
|
|
expect(sent.last.$2, {
|
|
'requestId': 'synthetic-passed',
|
|
'status': 'authorized',
|
|
'token': 'synthetic-token',
|
|
});
|
|
expect(biometry.authReasons, ['Synthetic reason', null]);
|
|
},
|
|
);
|
|
|
|
test('does not invent a token when none was saved', () async {
|
|
await grantAccess();
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-auth"}',
|
|
false,
|
|
);
|
|
|
|
expect(
|
|
sent.single.$2,
|
|
errorOf('biometry_request_auth', 'not_found', 'synthetic-auth'),
|
|
);
|
|
expect(secureValues, isEmpty);
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
|
|
test('saves a token only after the biometric check passes', () async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
biometry.passes = false;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"synthetic-denied","token":"synthetic-replacement"}',
|
|
false,
|
|
);
|
|
expect(secureValues, {tokenKey: 'synthetic-token'});
|
|
|
|
biometry.passes = true;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"synthetic-passed","token":"synthetic-replacement"}',
|
|
false,
|
|
);
|
|
|
|
expect(
|
|
sent.first.$2,
|
|
errorOf('biometry_update_token', 'auth_failed', 'synthetic-denied'),
|
|
);
|
|
expect(sent.last.$2, {
|
|
'requestId': 'synthetic-passed',
|
|
'status': 'updated',
|
|
});
|
|
expect(secureValues, {tokenKey: 'synthetic-replacement'});
|
|
});
|
|
|
|
test(
|
|
'refuses a token from an app without access or one too large',
|
|
() async {
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"synthetic-stranger","token":"synthetic-replacement"}',
|
|
false,
|
|
);
|
|
await grantAccess();
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
jsonEncode({'requestId': 'synthetic-large', 'token': 'x' * 1025}),
|
|
false,
|
|
);
|
|
|
|
expect(sent.map((event) => event.$2), [
|
|
errorOf(
|
|
'biometry_update_token',
|
|
'permission_denied',
|
|
'synthetic-stranger',
|
|
),
|
|
errorOf('biometry_update_token', 'too_large', 'synthetic-large'),
|
|
]);
|
|
expect(secureValues, isEmpty);
|
|
expect(biometry.authReasons, isEmpty);
|
|
},
|
|
);
|
|
|
|
test('removes the token without asking for a check', () async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryUpdateToken',
|
|
'{"requestId":"synthetic-remove","token":""}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.single.$2, {
|
|
'requestId': 'synthetic-remove',
|
|
'status': 'removed',
|
|
});
|
|
expect(secureValues, isEmpty);
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
|
|
test(
|
|
'settings let the user change the decision and restart the app',
|
|
() async {
|
|
await AppDatabase.setWebAppBiometryAccess(
|
|
accountId,
|
|
botId,
|
|
requested: true,
|
|
granted: false,
|
|
);
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
biometry.consent = false;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryOpenSettings',
|
|
'{"requestId":"synthetic-kept"}',
|
|
false,
|
|
);
|
|
expect(closeCalls, 0);
|
|
|
|
biometry.consent = true;
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryOpenSettings',
|
|
'{"requestId":"synthetic-changed"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.map((event) => event.$2), [
|
|
{'requestId': 'synthetic-kept', 'status': 'opened'},
|
|
{'requestId': 'synthetic-changed', 'status': 'opened'},
|
|
]);
|
|
expect(closeCalls, 1);
|
|
expect(await AppDatabase.getWebAppBiometryAccess(accountId, botId), (
|
|
true,
|
|
true,
|
|
));
|
|
},
|
|
);
|
|
|
|
test('answers on the channel the request came from', () async {
|
|
await grantAccess(token: 'synthetic-token');
|
|
final bridge = buildBridge(privateChannel: true, biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryGetInfo',
|
|
'{"requestId":"synthetic-info"}',
|
|
true,
|
|
);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-auth"}',
|
|
true,
|
|
);
|
|
|
|
expect(sent.map((event) => event.$3), [true, true]);
|
|
expect(sent.last.$2['token'], 'synthetic-token');
|
|
});
|
|
|
|
test('answers nothing useful without an active account', () async {
|
|
SharedPreferences.setMockInitialValues({});
|
|
final bridge = buildBridge(biometry: biometry);
|
|
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryGetInfo',
|
|
'{"requestId":"synthetic-info"}',
|
|
false,
|
|
);
|
|
await bridge.handleEvent(
|
|
'WebAppBiometryRequestAuth',
|
|
'{"requestId":"synthetic-auth"}',
|
|
false,
|
|
);
|
|
|
|
expect(sent.first.$2['available'], isFalse);
|
|
expect(
|
|
sent.last.$2,
|
|
errorOf('biometry_request_auth', 'access_denied', 'synthetic-auth'),
|
|
);
|
|
expect(biometry.authReasons, isEmpty);
|
|
});
|
|
});
|
|
}
|