import 'package:flutter_test/flutter_test.dart'; import 'package:halkyon_app/halkyon_app.dart'; import 'package:halkyon_app/testing.dart'; import 'package:halkyon_oidc_flutter/halkyon_oidc_flutter.dart'; import 'package:material_ui/material_ui.dart'; /// A signed-in person's app as a test makes one: whose it is. class const _PersonApp(final SignedIn person) extends StatelessWidget { @override Widget build(BuildContext context) => MaterialApp( home: Scaffold(body: Text('App of ${person.authUserId}')), ); } const password = 'correct horse battery'; void main() { late FakeAuthGateway gateway; late TestAppDependencies dependencies; setUp(() { gateway = FakeAuthGateway(status: const SignedOut()); dependencies = TestAppDependencies(gateway: gateway); dependencies.simpleLoginGateway.isOffered = true; }); /// Starts signed out, on a phone tall enough for every sheet. Future start(WidgetTester tester) async { tester.view.physicalSize = const Size(412, 1800); tester.view.devicePixelRatio = 1; addTearDown(tester.view.reset); await tester.pumpWidget( AppFrameScope( frame: testFrame(), child: AppScope( dependencies: dependencies, child: SessionGate( look: (context) => (themeMode: ThemeMode.light, locale: null), app: _PersonApp.new, ), ), ), ); await tester.pumpAndSettle(); } Finder field(String label) => find.widgetWithText(TextField, label); Future tapButton(WidgetTester tester, String label) async { final button = find.widgetWithText(FilledButton, label).last; await tester.ensureVisible(button); await tester.tap(button); await tester.pumpAndSettle(); } /// Types the recovery phrase's words the check asks for. Future typeCheckedWords(WidgetTester tester, String phrase) async { final words = wordsOf(phrase); for (final element in find.byType(TextField).evaluate()) { if ((element.widget as TextField).decoration?.labelText case final label? when label.startsWith('Word ')) { await tester.enterText( field(label), words[int.parse(label.substring('Word '.length)) - 1], ); } } } testWidgets('the start screen offers SimpleLogin first, saying what it ' 'learns and what the instance keeps', (tester) async { await start(tester); expect( find.widgetWithText(FilledButton, 'Continue with SimpleLogin'), findsOneWidget, ); expect( find.textContaining('keeps only your SimpleLogin ID'), findsOneWidget, ); expect(find.textContaining('test.example.org'), findsWidgets); }); testWidgets('offers no SimpleLogin where the instance has none', ( tester, ) async { dependencies.simpleLoginGateway.isOffered = false; await start(tester); expect(find.text('Continue with SimpleLogin'), findsNothing); }); testWidgets('someone new creates an account: the handle suggested, the ' 'password twice, the phrase written down, then signed in', ( tester, ) async { await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); expect(find.text('New here?'), findsOneWidget); await tester.tap(find.widgetWithText(ListTile, 'Create an account')); await tester.pumpAndSettle(); expect(find.text('anna_mueller'), findsOneWidget); expect(find.text('@anna_mueller is free'), findsOneWidget); await tester.enterText(field('Handle'), 'anna_m'); await tester.pump(); expect(find.text('@anna_mueller is free'), findsNothing); expect(find.text('Checking…'), findsOneWidget); await tester.enterText(field('Handle'), 'anna_mueller'); await tester.pump(); // The suggestion is free without asking (ADR 0020, D30). expect(find.text('@anna_mueller is free'), findsOneWidget); await tapButton(tester, 'Continue'); await tester.enterText(field('New password'), password); await tester.enterText(field('New password again'), password); await tapButton(tester, 'Continue'); expect(find.text('Your recovery phrase'), findsOneWidget); await tapButton(tester, "I've written it down"); await typeCheckedWords(tester, fakePhrase); await tapButton(tester, 'Create the account'); expect(find.text('App of person-1'), findsOneWidget); }); testWidgets('someone new is told a handle is taken as they type it, and ' 'goes no further with it', (tester) async { dependencies.simpleLoginGateway.addAccount( 'person-9', subject: 'simplelogin-other', handle: 'anna', keys: (await dependencies.crypto.newKeys(password)).setup, ); await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); await tester.tap(find.widgetWithText(ListTile, 'Create an account')); await tester.pumpAndSettle(); await tester.enterText(field('Handle'), '@anna'); await tester.pump(HandleCheckCubit.pause); await tester.pumpAndSettle(); expect(find.text('Someone else has this handle.'), findsOneWidget); await tapButton(tester, 'Continue'); expect(field('New password'), findsNothing); await tester.enterText(field('Handle'), 'anna_k'); await tester.pump(HandleCheckCubit.pause); await tester.pumpAndSettle(); expect(find.text('@anna_k is free'), findsOneWidget); await tapButton(tester, 'Continue'); expect(field('New password'), findsOneWidget); }); testWidgets('an account opens its keys with its password, a wrong one ' 'refused', (tester) async { final material = await dependencies.crypto.newKeys(password); dependencies.simpleLoginGateway.addAccount( 'person-9', subject: 'simplelogin-anna', handle: 'anna', keys: material.setup, ); await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); expect(find.textContaining('as @anna'), findsOneWidget); await tester.enterText(field('Password'), 'wrong horse battery'); await tapButton(tester, 'Open'); expect(find.text("The password isn't right."), findsOneWidget); await tester.enterText(field('Password'), password); await tapButton(tester, 'Open'); expect(find.text('App of person-9'), findsOneWidget); }); testWidgets('an account whose password was lost opens its keys with the ' 'recovery phrase under a new password, a phrase that is not its own ' 'refused', (tester) async { final material = await dependencies.crypto.newKeys(password); dependencies.simpleLoginGateway.addAccount( 'person-9', subject: 'simplelogin-anna', handle: 'anna', keys: material.setup, ); await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?')); await tester.pumpAndSettle(); expect(find.text('A new password'), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Back')); await tester.pumpAndSettle(); expect(find.text('Your password'), findsOneWidget); await tester.tap(find.widgetWithText(TextButton, 'Forgot your password?')); await tester.pumpAndSettle(); const newPassword = 'a new horse battery'; await tester.enterText(field('Recovery phrase'), otherFakePhrase); await tester.enterText(field('New password'), newPassword); await tester.enterText(field('New password again'), newPassword); await tapButton(tester, 'Sign in'); expect(find.text("The recovery phrase isn't right."), findsOneWidget); await tester.enterText(field('Recovery phrase'), fakePhrase); await tapButton(tester, 'Sign in'); expect(find.text('App of person-9'), findsOneWidget); expect(dependencies.keys.kept, {'person-9': material.keys}); }); testWidgets('someone whose SimpleLogin account was lost links theirs ' 'again with its handle and recovery phrase', (tester) async { final material = await dependencies.crypto.newKeys(password); dependencies.simpleLoginGateway.addAccount( 'person-9', subject: 'simplelogin-lost', handle: 'anna', keys: material.setup, ); await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); await tester.tap(find.widgetWithText(ListTile, 'I already have one')); await tester.pumpAndSettle(); expect(find.text('Link your account'), findsOneWidget); expect(find.text('anna_mueller'), findsNothing); await tester.enterText(field('Handle'), 'anna'); await tester.enterText(find.byType(TextField).last, otherFakePhrase); await tapButton(tester, 'Link'); expect( find.text("The handle or the recovery phrase isn't right."), findsOneWidget, ); await tester.enterText(find.byType(TextField).last, fakePhrase); await tapButton(tester, 'Link'); expect(find.text('App of person-9'), findsOneWidget); }); testWidgets('closing the browser leaves the start screen as it was', ( tester, ) async { dependencies.browser.comesBack = () => null; await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); expect(find.text('New here?'), findsNothing); expect( find.widgetWithText(FilledButton, 'Continue with SimpleLogin'), findsOneWidget, ); }); testWidgets("a refusal on SimpleLogin's page shows, and it begins again", ( tester, ) async { dependencies.browser.comesBack = () => const OidcDenied('access_denied'); await start(tester); await tapButton(tester, 'Continue with SimpleLogin'); expect(find.textContaining('nothing was shared'), findsOneWidget); dependencies.browser.comesBack = null; await tapButton(tester, 'Try again'); expect(find.text('New here?'), findsOneWidget); }); for (final locale in HalkyonAppLocalizations.supportedLocales) { testWidgets('every refusal has its words in $locale', (tester) async { late BuildContext context; await tester.pumpWidget( MaterialApp( locale: locale, localizationsDelegates: testLocalizationsDelegates, supportedLocales: HalkyonAppLocalizations.supportedLocales, home: Builder( builder: (built) { context = built; return const SizedBox(); }, ), ), ); await tester.pumpAndSettle(); for (final problem in SimpleLoginProblem.values) { expect( simpleLoginFailureText(context, SimpleLoginFailed(problem)), isNotEmpty, reason: '$problem', ); } }); } }