Add reusable two-factor verification for login and money operations (#32)
This commit is contained in:
1 parent
b7a58b1a87
commit
fb40f860d2
41 files changed
+2439
-43
No files matched your search
@@ -60,7 +60,13 @@ void main() {
|
||||
final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map;
|
||||
expect(ru.keys.toSet(), en.keys.toSet());
|
||||
expect(
|
||||
ru.values.every((value) => value is String && value.trim().isNotEmpty),
|
||||
ru.entries
|
||||
.where((entry) => !entry.key.toString().startsWith('@'))
|
||||
.every(
|
||||
(entry) =>
|
||||
entry.value is String &&
|
||||
(entry.value as String).trim().isNotEmpty,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
Future<void> tapFactor(WidgetTester t, String key) async {
|
||||
final finder = find.byKey(ValueKey(key));
|
||||
await t.ensureVisible(finder);
|
||||
await t.tap(finder);
|
||||
await t.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<DummyTwoFactorService> openFactor(
|
||||
WidgetTester t, {
|
||||
String path = '/settings',
|
||||
bool enabled = false,
|
||||
String locale = 'en',
|
||||
FactorMethod method = FactorMethod.email,
|
||||
DummyAnalyticsService? analytics,
|
||||
}) async {
|
||||
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
final service = DummyTwoFactorService(
|
||||
enabled: enabled,
|
||||
delay: Duration.zero,
|
||||
method: method,
|
||||
);
|
||||
await t.pumpWidget(
|
||||
SxchngeApp(
|
||||
twoFactorService: service,
|
||||
analyticsService: analytics,
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||
),
|
||||
);
|
||||
await t.pumpAndSettle();
|
||||
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||
return service;
|
||||
}
|
||||
|
||||
Future<void> signIn(WidgetTester t) async {
|
||||
await t.enterText(find.byKey(const ValueKey('email')), 'person@example.com');
|
||||
await t.enterText(find.byKey(const ValueKey('password')), 'example-password');
|
||||
await tapFactor(t, 'submit');
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('settings enable and disable only after verified code', (
|
||||
t,
|
||||
) async {
|
||||
final service = await openFactor(t);
|
||||
expect(find.text('Off'), findsOneWidget);
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
expect((await t.runAsync(service.fetch))!.enabled, false);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '000000');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.byKey(const ValueKey('two-factor-error')), findsOneWidget);
|
||||
expect((await t.runAsync(service.fetch))!.enabled, false);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.text('On'), findsOneWidget);
|
||||
expect((await t.runAsync(service.fetch))!.enabled, true);
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
expect(find.textContaining('turn off two-factor'), findsOneWidget);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.text('Off'), findsOneWidget);
|
||||
expect((await t.runAsync(service.fetch))!.enabled, false);
|
||||
});
|
||||
for (final entry in {
|
||||
FactorError.expired: 'This verification has expired.',
|
||||
FactorError.locked: 'Too many incorrect attempts.',
|
||||
FactorError.unavailable: 'Could not complete verification.',
|
||||
}.entries) {
|
||||
testWidgets(
|
||||
'${entry.key.name} is recoverable without enabling protection',
|
||||
(t) async {
|
||||
final service = await openFactor(t);
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
service.nextError = entry.key;
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('two-factor-code')),
|
||||
'123456',
|
||||
);
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.textContaining(entry.value), findsOneWidget);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
expect(find.text('Off'), findsOneWidget);
|
||||
expect((await t.runAsync(service.fetch))!.enabled, false);
|
||||
},
|
||||
);
|
||||
}
|
||||
testWidgets('challenge creation failure is measured and can retry', (
|
||||
t,
|
||||
) async {
|
||||
final analytics = DummyAnalyticsService();
|
||||
final service = await openFactor(t, analytics: analytics);
|
||||
service.nextError = FactorError.unavailable;
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
expect(
|
||||
find.textContaining('Could not complete verification.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
analytics.events.any(
|
||||
(e) =>
|
||||
e.step == JourneyStep.factorChallenge &&
|
||||
e.failure == JourneyFailure.unavailable,
|
||||
),
|
||||
true,
|
||||
);
|
||||
expect(analytics.events.where((e) => e.terminal), isEmpty);
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
});
|
||||
testWidgets('cancel enrollment leaves setting unchanged', (t) async {
|
||||
final service = await openFactor(t);
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
expect(find.text('Off'), findsOneWidget);
|
||||
expect((await t.runAsync(service.fetch))!.enabled, false);
|
||||
});
|
||||
testWidgets('login is not terminal until factor and completion succeed', (
|
||||
t,
|
||||
) async {
|
||||
final analytics = DummyAnalyticsService();
|
||||
await openFactor(t, path: '/', enabled: true, analytics: analytics);
|
||||
await signIn(t);
|
||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(
|
||||
analytics.events.where((e) => e.kind == JourneyKind.login && e.terminal),
|
||||
isEmpty,
|
||||
);
|
||||
await t.enterText(find.byKey(const ValueKey('two-factor-code')), '123456');
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.byKey(const ValueKey('account-eur')), findsOneWidget);
|
||||
final login = analytics.events
|
||||
.where((e) => e.kind == JourneyKind.login)
|
||||
.toList();
|
||||
expect(login.where((e) => e.terminal).length, 1);
|
||||
expect(login.map((e) => e.journeyId).toSet().length, 1);
|
||||
expect(
|
||||
login.map((e) => e.properties).toString(),
|
||||
isNot(contains('123456')),
|
||||
);
|
||||
});
|
||||
testWidgets('cancel login factor returns without a successful session', (
|
||||
t,
|
||||
) async {
|
||||
await openFactor(t, path: '/', enabled: true);
|
||||
await signIn(t);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(
|
||||
t
|
||||
.widget<TextFormField>(find.byKey(const ValueKey('password')))
|
||||
.controller!
|
||||
.text,
|
||||
isEmpty,
|
||||
);
|
||||
});
|
||||
for (final kind in ['top-up', 'exchange', 'payout']) {
|
||||
testWidgets(
|
||||
'$kind confirms specific transaction and cancel preserves review',
|
||||
(t) async {
|
||||
await openFactor(t, path: '/$kind', enabled: true);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
if (kind == 'payout') {
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
}
|
||||
await tapFactor(t, 'transfer-review');
|
||||
await tapFactor(t, 'transfer-confirm');
|
||||
expect(find.byKey(const ValueKey('two-factor-code')), findsOneWidget);
|
||||
expect(find.textContaining('No money moves'), findsOneWidget);
|
||||
expect(find.text('Fee'), findsOneWidget);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
expect(find.byKey(const ValueKey('transfer-confirm')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('operation-pending')), findsNothing);
|
||||
},
|
||||
);
|
||||
}
|
||||
testWidgets(
|
||||
'money moves after verification and confirmation, without another submit',
|
||||
(t) async {
|
||||
await openFactor(t, path: '/exchange', enabled: true);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
await tapFactor(t, 'transfer-review');
|
||||
await tapFactor(t, 'transfer-confirm');
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('two-factor-code')),
|
||||
'123456',
|
||||
);
|
||||
await tapFactor(t, 'two-factor-confirm');
|
||||
expect(find.byKey(const ValueKey('operation-pending')), findsOneWidget);
|
||||
expect(find.textContaining('amount and fee have left'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
testWidgets('authenticator uses SXCHNGE page without email or resend UI', (
|
||||
t,
|
||||
) async {
|
||||
await openFactor(
|
||||
t,
|
||||
path: '/',
|
||||
enabled: true,
|
||||
method: FactorMethod.authenticator,
|
||||
);
|
||||
await signIn(t);
|
||||
expect(find.textContaining('authenticator app'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('two-factor-resend')), findsNothing);
|
||||
expect(find.text('a•••@example.com'), findsNothing);
|
||||
});
|
||||
testWidgets('Russian factor screen fits small viewport and enlarged text', (
|
||||
t,
|
||||
) async {
|
||||
t.view.physicalSize = const Size(320, 1100);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
addTearDown(t.view.resetPhysicalSize);
|
||||
addTearDown(t.view.resetDevicePixelRatio);
|
||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await openFactor(t, locale: 'ru');
|
||||
await tapFactor(t, 'two-factor-setting');
|
||||
expect(find.text('Ещё одна проверка.'), findsOneWidget);
|
||||
expect(t.takeException(), null);
|
||||
await tapFactor(t, 'two-factor-cancel');
|
||||
});
|
||||
testWidgets('missing challenge deep link returns to login', (t) async {
|
||||
await openFactor(t, path: '/two-factor');
|
||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('two-factor-code')), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user