An account that signs in with SimpleLogin opens its keys with its password. Where that is lost, the unlock step's "Forgot your password?" opens one step: the recovery phrase, and a new password twice. There is no address and no code: the SimpleLogin sign-in of a moment ago stands for them (D20). - The device opens the keys with the phrase (`keys.beginPhrase`) and wraps them under the new password (`AccountCrypto.newKeysPassword`). It then signs the phrase's proof bound to that password (`signKeysResetProof`), which `keys.resetPassword` takes with the sign-in. The keys stay, and every other session ends. - A phrase that doesn't open the keys is refused on the device, and the instance gets no proof to count. Two passwords that differ are refused before anything is sent. - The gateway's `beginPhrase` and `resetPassword` run on the account's own session, not yet the device's. The repository's `resetPassword` ties them together, and the cubit and sheet have their `forgot` step. Three texts are new, in English and German. - halkyon_base_server exports `isKeysPassword` for the interop test, which checks against the register's checks, with real libsodium, what the device sends. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
303 lines
10 KiB
Dart
303 lines
10 KiB
Dart
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<void> 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<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();
|
|
}
|
|
|
|
/// Types the recovery phrase's words the check asks for.
|
|
Future<void> 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',
|
|
);
|
|
}
|
|
});
|
|
}
|
|
}
|