Files
sxchnge/frontend/app/test/two_factor_test.dart

244 lines
9.0 KiB
Dart

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