test(app): the password screens, signed out and signed in
ADR 0017, step 3: widget tests driving the sheets as a person would, on halkyon_app's fakes: signing in with a code (a wrong one refused alike), an account never finished signing in with a code by mail and setting up its authenticator (QR code, key, recovery codes saved), a lost authenticator's reset, creating an account (the phrase shown and three of its words checked before anything reaches the instance), the recovery phrase getting back in, and the account section (a password offered to someone anonymous; an authenticator added only with a code from one before; the password changed). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a4819c3d39
commit
735dfa892a
2 changed files with 369 additions and 1 deletions
|
|
@ -13,7 +13,9 @@
|
|||
/// `resolveAppLocale`, the language every Halkyon app picks.
|
||||
/// - **Signing in**: anonymously, with GitHub or with a code by mail
|
||||
/// (`AuthRepository` on `ServerpodAuthGateway`), and the blocs and sheet
|
||||
/// that switch a device to another account.
|
||||
/// that switch a device to another account; with a password and TOTP
|
||||
/// (ADR 0017: `PasswordRepository`, its sheets and
|
||||
/// `AccountSecuritySection`), the account's keys on the device.
|
||||
/// - **Sync**: `SyncService` on triggers, the changes still waiting, and
|
||||
/// where it stands (`SyncStatusBloc`, `SyncStatusCard`, `AppMismatchBanner`,
|
||||
/// `SyncTroubleBadge`).
|
||||
|
|
@ -69,6 +71,10 @@ export 'src/instance/widget/instance_location.dart';
|
|||
export 'src/instance/widget/instance_location_words.dart';
|
||||
export 'src/instance/widget/instance_step.dart';
|
||||
export 'src/instance/widget/instance_tile.dart';
|
||||
export 'src/password/bloc/account_recovery_cubit.dart';
|
||||
export 'src/password/bloc/account_security_cubit.dart';
|
||||
export 'src/password/bloc/account_setup_cubit.dart';
|
||||
export 'src/password/bloc/password_sign_in_cubit.dart';
|
||||
export 'src/password/data/account_crypto.dart';
|
||||
export 'src/password/data/account_key_store.dart';
|
||||
export 'src/password/data/password_gateway.dart';
|
||||
|
|
@ -80,6 +86,12 @@ export 'src/password/model/account_security.dart';
|
|||
export 'src/password/model/password_failed.dart';
|
||||
export 'src/password/model/phrase_check.dart';
|
||||
export 'src/password/model/unlocked_account_keys.dart';
|
||||
export 'src/password/widget/account_recovery_sheet.dart';
|
||||
export 'src/password/widget/account_security_section.dart';
|
||||
export 'src/password/widget/account_setup_sheet.dart';
|
||||
export 'src/password/widget/password_sign_in_sheet.dart';
|
||||
export 'src/password/widget/password_texts.dart';
|
||||
export 'src/password/widget/password_widgets.dart';
|
||||
export 'src/person/person_folders.dart';
|
||||
export 'src/sync/app_compatibility.dart';
|
||||
export 'src/sync/bloc/sync_status_bloc.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,356 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
import 'package:halkyon_app/testing.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}')),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
late FakeAuthGateway gateway;
|
||||
late TestAppDependencies dependencies;
|
||||
|
||||
setUp(() {
|
||||
gateway = FakeAuthGateway();
|
||||
dependencies = TestAppDependencies(gateway: gateway);
|
||||
});
|
||||
|
||||
/// Starts signed out, on a phone tall enough for every sheet.
|
||||
Future<void> start(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await dependencies.signedOut.write(signedOut: true);
|
||||
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<void> tapButton(WidgetTester tester, String label) async {
|
||||
final button = find.widgetWithText(FilledButton, label).last;
|
||||
await tester.ensureVisible(button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
/// The labels of the fields asking for words of the recovery phrase.
|
||||
List<String> wordLabels() => [
|
||||
for (final element in find.byType(TextField).evaluate())
|
||||
if ((element.widget as TextField).decoration?.labelText case final label?
|
||||
when label.startsWith('Word '))
|
||||
label,
|
||||
];
|
||||
|
||||
/// Types the recovery phrase's words the check asks for, or [wrong] in
|
||||
/// each.
|
||||
Future<void> typeCheckedWords(
|
||||
WidgetTester tester,
|
||||
String phrase, {
|
||||
String? wrong,
|
||||
}) async {
|
||||
final words = wordsOf(phrase);
|
||||
for (final label in wordLabels()) {
|
||||
await tester.enterText(
|
||||
field(label),
|
||||
wrong ?? words[int.parse(label.substring('Word '.length)) - 1],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
group('signed out', () {
|
||||
testWidgets('signs in with the address, the password and a code, the '
|
||||
'keys kept on the device', (tester) async {
|
||||
final anna = dependencies.passwordGateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: dependencies.crypto,
|
||||
);
|
||||
await start(tester);
|
||||
expect(find.text('Create an account'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'anna@example.org');
|
||||
await tester.enterText(field('Password'), 'correct horse');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
'000000',
|
||||
);
|
||||
await tapButton(tester, 'Sign in');
|
||||
expect(
|
||||
find.text("The address, the password or the code isn't right."),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Sign in');
|
||||
|
||||
expect(find.text('App of ${anna.person}'), findsOneWidget);
|
||||
expect(dependencies.keys.kept.keys, [anna.person]);
|
||||
expect(dependencies.addresses.addresses[anna.person], 'anna@example.org');
|
||||
});
|
||||
|
||||
testWidgets('an account never finished signs in with a code by mail, '
|
||||
'then sets up its authenticator and keeps its recovery '
|
||||
'codes', (tester) async {
|
||||
final carla = dependencies.passwordGateway.addAccount(
|
||||
'carla@example.org',
|
||||
password: 'battery staple',
|
||||
crypto: dependencies.crypto,
|
||||
finished: false,
|
||||
);
|
||||
await start(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'carla@example.org');
|
||||
await tester.enterText(field('Password'), 'battery staple');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Sign in');
|
||||
|
||||
expect(find.textContaining('never finished'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
field('Code from the email'),
|
||||
dependencies.passwordGateway.codeFor('carla@example.org'),
|
||||
);
|
||||
await tapButton(tester, 'Sign in');
|
||||
|
||||
expect(find.byType(QrCodeView), findsOneWidget);
|
||||
expect(find.text('JBSW Y3DP EHPK 3PXP'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(find.text('AAAA-0001'), findsOneWidget);
|
||||
await tester.tap(find.text('Save as a file'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(dependencies.fileSaver.saved.single.name, contains('codes'));
|
||||
expect(
|
||||
utf8.decode(dependencies.fileSaver.saved.single.bytes),
|
||||
startsWith('AAAA-0001\nAAAA-0002'),
|
||||
);
|
||||
await tapButton(tester, "I've kept them");
|
||||
|
||||
expect(find.text('App of ${carla.person}'), findsOneWidget);
|
||||
expect(carla.finished, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('a lost authenticator: asks for its reset with a code by '
|
||||
'mail, saying when it takes effect', (tester) async {
|
||||
dependencies.passwordGateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: dependencies.crypto,
|
||||
);
|
||||
await start(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'anna@example.org');
|
||||
await tester.enterText(field('Password'), 'correct horse');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.tap(find.text('Lost your authenticator?'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Reset with a code by email'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
field('Code from the email'),
|
||||
dependencies.passwordGateway.codeFor('anna@example.org'),
|
||||
);
|
||||
await tapButton(tester, 'Reset');
|
||||
|
||||
expect(
|
||||
find.textContaining('Your authenticators are reset on'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('creates an account: the phrase written down and checked '
|
||||
'before the code, then the authenticator', (tester) async {
|
||||
await start(tester);
|
||||
await tester.tap(find.text('Create an account'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'ben@example.org');
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(field('New password'), 'a new password');
|
||||
await tester.enterText(field('New password again'), 'another');
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(find.text('The two passwords differ.'), findsOneWidget);
|
||||
await tester.enterText(field('New password again'), 'a new password');
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
expect(find.text('Your recovery phrase'), findsOneWidget);
|
||||
expect(find.text('1. abandon', findRichText: true), findsOneWidget);
|
||||
expect(
|
||||
dependencies.passwordGateway.mailed,
|
||||
isEmpty,
|
||||
reason: 'nothing reaches the instance before the phrase is checked',
|
||||
);
|
||||
await tapButton(tester, "I've written it down");
|
||||
expect(wordLabels(), hasLength(3));
|
||||
await typeCheckedWords(tester, fakePhrase, wrong: 'wrong');
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(
|
||||
find.text(
|
||||
"These aren't the words. Look again at what you wrote down.",
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
await typeCheckedWords(tester, fakePhrase);
|
||||
await tapButton(tester, 'Continue');
|
||||
|
||||
await tester.enterText(
|
||||
field('Code from the email'),
|
||||
dependencies.passwordGateway.codeFor('ben@example.org'),
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
await tapButton(tester, "I've kept them");
|
||||
|
||||
final ben = dependencies.passwordGateway.accounts['ben@example.org']!;
|
||||
expect(find.text('App of ${ben.person}'), findsOneWidget);
|
||||
expect(ben.password, 'a new password');
|
||||
expect(ben.finished, isTrue);
|
||||
expect(dependencies.keys.kept.keys, [ben.person]);
|
||||
});
|
||||
|
||||
testWidgets('gets back in with the recovery phrase', (tester) async {
|
||||
final anna = dependencies.passwordGateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'forgotten',
|
||||
crypto: dependencies.crypto,
|
||||
);
|
||||
await start(tester);
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Sign in'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Forgot your password?'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('I have my recovery phrase'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Email address'), 'anna@example.org');
|
||||
await tester.enterText(field('Recovery phrase'), fakePhrase);
|
||||
await tester.enterText(field('New password'), 'remembered');
|
||||
await tester.enterText(field('New password again'), 'remembered');
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Sign in');
|
||||
|
||||
expect(find.text('App of ${anna.person}'), findsOneWidget);
|
||||
expect(anna.password, 'remembered');
|
||||
});
|
||||
});
|
||||
|
||||
group('the account section', () {
|
||||
Future<void> show(WidgetTester tester) async {
|
||||
tester.view.physicalSize = const Size(412, 1800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
testApp(
|
||||
Scaffold(
|
||||
body: ListView(children: const [AccountSecuritySection()]),
|
||||
),
|
||||
dependencies: dependencies,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
testWidgets('offers an anonymous person a password', (tester) async {
|
||||
await gateway.signInAnonymously();
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Set up a password'), findsOneWidget);
|
||||
expect(find.text('Change password'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('once set up, changes the password and adds an authenticator '
|
||||
'only with a code from one before', (tester) async {
|
||||
final anna = dependencies.passwordGateway.addAccount(
|
||||
'anna@example.org',
|
||||
password: 'correct horse',
|
||||
crypto: dependencies.crypto,
|
||||
);
|
||||
final passwords = dependencies.passwords;
|
||||
await passwords.enter(
|
||||
await passwords.signIn(
|
||||
await passwords.unlock('anna@example.org', 'correct horse'),
|
||||
totpCode: fakeTotpCode,
|
||||
),
|
||||
);
|
||||
await show(tester);
|
||||
|
||||
expect(find.text('Phone'), findsOneWidget);
|
||||
await tester.tap(find.text('Add an authenticator'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tester.enterText(
|
||||
field('Code from an authenticator you added before'),
|
||||
'000000',
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(
|
||||
find.text("The code from your other authenticator isn't right."),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.enterText(
|
||||
field('Code from an authenticator you added before'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Continue');
|
||||
expect(anna.authenticators.where((each) => each.confirmed), hasLength(2));
|
||||
|
||||
await tester.tap(find.text('Change password'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(field('Current password'), 'correct horse');
|
||||
await tester.enterText(field('New password'), 'better horse');
|
||||
await tester.enterText(field('New password again'), 'better horse');
|
||||
await tester.enterText(
|
||||
field('Code from your authenticator app'),
|
||||
fakeTotpCode,
|
||||
);
|
||||
await tapButton(tester, 'Change password');
|
||||
|
||||
expect(anna.password, 'better horse');
|
||||
expect(
|
||||
find.text('Password changed. Your other devices are signed out.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue